diff --git a/apps/docs/content/docs/en/tools/hunter.mdx b/apps/docs/content/docs/en/tools/hunter.mdx index d1ea0fff4aa..a87b1f9e151 100644 --- a/apps/docs/content/docs/en/tools/hunter.mdx +++ b/apps/docs/content/docs/en/tools/hunter.mdx @@ -1,5 +1,5 @@ --- -title: Hunter io +title: Hunter.io description: Find and verify professional email addresses --- @@ -53,11 +53,16 @@ Returns companies matching a set of criteria using Hunter.io AI-powered search. | Parameter | Type | Description | | --------- | ---- | ----------- | | `results` | array | List of companies matching the search criteria | +| ↳ `name` | string | Company name | | ↳ `domain` | string | Company domain | -| ↳ `name` | string | Company/organization name | -| ↳ `headcount` | number | Company size/headcount | -| ↳ `technologies` | array | Technologies used by the company | -| ↳ `email_count` | number | Total number of email addresses found | +| ↳ `logo` | string | URL of the company logo | +| ↳ `linkedin_url` | string | LinkedIn profile URL of the company | +| ↳ `company_type` | string | Company type \(e.g., privately held, public company\) | +| ↳ `industry` | string | Industry of the company | +| ↳ `size` | string | Headcount range of the company | +| ↳ `location` | string | Headquarters location | +| ↳ `founded_year` | number | Year the company was founded | +| ↳ `crunchbase_url` | string | Crunchbase URL of the company | ### `hunter_domain_search` @@ -86,8 +91,9 @@ Returns all the email addresses found using one given domain name, with sources. | ↳ `first_name` | string | Person's first name | | ↳ `last_name` | string | Person's last name | | ↳ `position` | string | Job title/position | +| ↳ `position_raw` | string | Raw job title as found | | ↳ `seniority` | string | Seniority level \(junior, senior, executive\) | -| ↳ `department` | string | Department \(executive, it, finance, management, sales, legal, support, hr, marketing, communication\) | +| ↳ `department` | string | Department \(executive, it, finance, management, sales, legal, support, hr, marketing, communication, education, design, health, operations\) | | ↳ `linkedin` | string | LinkedIn profile URL | | ↳ `twitter` | string | Twitter handle | | ↳ `phone_number` | string | Phone number | @@ -106,19 +112,7 @@ Returns all the email addresses found using one given domain name, with sources. | `accept_all` | boolean | Whether the server accepts all email addresses \(may cause false positives\) | | `pattern` | string | The email pattern used by the organization \(e.g., \{first\}, \{first\}.\{last\}\) | | `organization` | string | The organization/company name | -| `description` | string | Description of the organization | -| `industry` | string | Industry classification of the organization | -| `twitter` | string | Twitter handle of the organization | -| `facebook` | string | Facebook page URL of the organization | -| `linkedin` | string | LinkedIn company page URL | -| `instagram` | string | Instagram profile of the organization | -| `youtube` | string | YouTube channel of the organization | -| `technologies` | array | Technologies used by the organization | -| `country` | string | Country where the organization is headquartered | -| `state` | string | State/province where the organization is located | -| `city` | string | City where the organization is located | -| `postal_code` | string | Postal code of the organization | -| `street` | string | Street address of the organization | +| `linked_domains` | array | Other domains linked to the organization | ### `hunter_email_finder` @@ -147,8 +141,17 @@ Finds the most likely email address for a person given their name and company do | `verification` | object | Email verification information | | ↳ `date` | string | Date when the email was verified \(YYYY-MM-DD\) | | ↳ `status` | string | Verification status \(valid, invalid, accept_all, webmail, disposable, unknown\) | +| `first_name` | string | Person's first name | +| `last_name` | string | Person's last name | | `email` | string | The found email address | | `score` | number | Confidence score \(0-100\) for the found email address | +| `domain` | string | Domain that was searched | +| `accept_all` | boolean | Whether the server accepts all email addresses \(may cause false positives\) | +| `position` | string | Job title/position | +| `twitter` | string | Twitter handle | +| `linkedin_url` | string | LinkedIn profile URL | +| `phone_number` | string | Phone number | +| `company` | string | Company name | ### `hunter_email_verifier` @@ -200,15 +203,24 @@ Enriches company data using domain name. | Parameter | Type | Description | | --------- | ---- | ----------- | -| `company` | object | Company information | -| ↳ `name` | string | Company name | -| ↳ `domain` | string | Company domain | -| ↳ `industry` | string | Industry classification | -| ↳ `size` | string | Company size/headcount range | -| ↳ `country` | string | Country where the company is located | -| ↳ `linkedin` | string | LinkedIn company page URL | -| ↳ `twitter` | string | Twitter handle | -| `person` | object | Person information \(undefined for companies_find tool\) | +| `name` | string | Company name | +| `domain` | string | Company domain | +| `description` | string | Company description | +| `industry` | string | Industry classification | +| `sector` | string | Business sector | +| `size` | string | Employee headcount range \(e.g., "11-50"\) | +| `founded_year` | number | Year founded | +| `location` | string | Headquarters location \(formatted\) | +| `country` | string | Country \(full name\) | +| `country_code` | string | ISO 3166-1 alpha-2 country code | +| `state` | string | State/province | +| `city` | string | City | +| `linkedin` | string | LinkedIn handle \(e.g., company/hunterio\) | +| `twitter` | string | Twitter handle | +| `facebook` | string | Facebook handle | +| `logo` | string | Company logo URL | +| `phone` | string | Company phone number | +| `tech` | array | Technologies used by the company | ### `hunter_email_count` diff --git a/apps/sim/app/(landing)/integrations/data/integrations.json b/apps/sim/app/(landing)/integrations/data/integrations.json index 08fa8bb80d7..7a812e339a8 100644 --- a/apps/sim/app/(landing)/integrations/data/integrations.json +++ b/apps/sim/app/(landing)/integrations/data/integrations.json @@ -6526,7 +6526,7 @@ { "type": "hunter", "slug": "hunter-io", - "name": "Hunter io", + "name": "Hunter.io", "description": "Find and verify professional email addresses", "longDescription": "Integrate Hunter into the workflow. Can search domains, find email addresses, verify email addresses, discover companies, find companies, and count email addresses.", "bgColor": "#E0E0E0", diff --git a/apps/sim/blocks/blocks/hunter.ts b/apps/sim/blocks/blocks/hunter.ts index 01aac501357..4c26bb85d43 100644 --- a/apps/sim/blocks/blocks/hunter.ts +++ b/apps/sim/blocks/blocks/hunter.ts @@ -4,7 +4,7 @@ import type { HunterResponse } from '@/tools/hunter/types' export const HunterBlock: BlockConfig = { type: 'hunter', - name: 'Hunter io', + name: 'Hunter.io', description: 'Find and verify professional email addresses', authMode: AuthMode.ApiKey, longDescription: @@ -45,6 +45,15 @@ export const HunterBlock: BlockConfig = { type: 'short-input', placeholder: '10', condition: { field: 'operation', value: 'hunter_domain_search' }, + mode: 'advanced', + }, + { + id: 'offset', + title: 'Offset', + type: 'short-input', + placeholder: '0', + condition: { field: 'operation', value: 'hunter_domain_search' }, + mode: 'advanced', }, { id: 'type', @@ -57,6 +66,7 @@ export const HunterBlock: BlockConfig = { ], value: () => 'all', condition: { field: 'operation', value: 'hunter_domain_search' }, + mode: 'advanced', }, { id: 'seniority', @@ -70,6 +80,7 @@ export const HunterBlock: BlockConfig = { ], value: () => 'all', condition: { field: 'operation', value: 'hunter_domain_search' }, + mode: 'advanced', }, { id: 'department', @@ -77,6 +88,7 @@ export const HunterBlock: BlockConfig = { type: 'short-input', placeholder: 'e.g., sales, marketing, engineering', condition: { field: 'operation', value: 'hunter_domain_search' }, + mode: 'advanced', }, // Email Finder operation inputs { @@ -109,6 +121,7 @@ export const HunterBlock: BlockConfig = { type: 'short-input', placeholder: 'Enter company name', condition: { field: 'operation', value: 'hunter_email_finder' }, + mode: 'advanced', }, // Email Verifier operation inputs { @@ -146,6 +159,54 @@ Return ONLY the search query text - no explanations.`, type: 'short-input', placeholder: 'Filter by domain', condition: { field: 'operation', value: 'hunter_discover' }, + mode: 'advanced', + }, + { + id: 'headcount', + title: 'Headcount', + type: 'dropdown', + options: [ + { label: 'Any', id: '' }, + { label: '1-10', id: '1-10' }, + { label: '11-50', id: '11-50' }, + { label: '51-200', id: '51-200' }, + { label: '201-500', id: '201-500' }, + { label: '501-1000', id: '501-1000' }, + { label: '1001-5000', id: '1001-5000' }, + { label: '5001-10000', id: '5001-10000' }, + { label: '10001+', id: '10001+' }, + ], + value: () => '', + condition: { field: 'operation', value: 'hunter_discover' }, + mode: 'advanced', + }, + { + id: 'company_type', + title: 'Company Type', + type: 'dropdown', + options: [ + { label: 'Any', id: '' }, + { label: 'Educational', id: 'educational' }, + { label: 'Government Agency', id: 'government agency' }, + { label: 'Non Profit', id: 'non profit' }, + { label: 'Partnership', id: 'partnership' }, + { label: 'Privately Held', id: 'privately held' }, + { label: 'Public Company', id: 'public company' }, + { label: 'Self Employed', id: 'self employed' }, + { label: 'Self Owned', id: 'self owned' }, + { label: 'Sole Proprietorship', id: 'sole proprietorship' }, + ], + value: () => '', + condition: { field: 'operation', value: 'hunter_discover' }, + mode: 'advanced', + }, + { + id: 'technology', + title: 'Technology', + type: 'short-input', + placeholder: 'e.g., react, salesforce', + condition: { field: 'operation', value: 'hunter_discover' }, + mode: 'advanced', }, // Find Company operation inputs @@ -172,6 +233,7 @@ Return ONLY the search query text - no explanations.`, type: 'short-input', placeholder: 'Enter company name', condition: { field: 'operation', value: 'hunter_email_count' }, + mode: 'advanced', }, { id: 'type', @@ -184,6 +246,7 @@ Return ONLY the search query text - no explanations.`, ], value: () => 'all', condition: { field: 'operation', value: 'hunter_email_count' }, + mode: 'advanced', }, // API Key (common) { @@ -225,7 +288,14 @@ Return ONLY the search query text - no explanations.`, }, params: (params) => { const result: Record = {} - if (params.limit) result.limit = Number(params.limit) + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null || value === '') continue + if (key === 'limit' || key === 'offset') { + result[key] = Number(value) + } else { + result[key] = value + } + } return result }, }, @@ -253,14 +323,88 @@ Return ONLY the search query text - no explanations.`, technology: { type: 'string', description: 'Technology filter' }, }, outputs: { - results: { type: 'json', description: 'Search results' }, - emails: { type: 'json', description: 'Email addresses found' }, + // Domain Search + domain: { type: 'string', description: 'Domain name' }, + organization: { type: 'string', description: 'Organization name (domain search)' }, + pattern: { type: 'string', description: 'Email pattern (e.g., {first}.{last})' }, + disposable: { type: 'boolean', description: 'Whether the domain is disposable' }, + webmail: { type: 'boolean', description: 'Whether the domain is a webmail provider' }, + accept_all: { type: 'boolean', description: 'Whether the server accepts all emails' }, + linked_domains: { type: 'array', description: 'Linked domains' }, + emails: { + type: 'array', + description: + 'List of emails found for the domain (value, type, confidence, first_name, last_name, position, seniority, department, linkedin, twitter, phone_number, sources, verification)', + }, + // Email Finder email: { type: 'string', description: 'Found email address' }, - score: { type: 'number', description: 'Confidence score' }, - result: { type: 'string', description: 'Verification result' }, - status: { type: 'string', description: 'Status message' }, - total: { type: 'number', description: 'Total results count' }, + score: { type: 'number', description: 'Confidence score (0-100)' }, + first_name: { type: 'string', description: 'Person first name' }, + last_name: { type: 'string', description: 'Person last name' }, + position: { type: 'string', description: 'Job position' }, + linkedin_url: { type: 'string', description: 'LinkedIn profile URL (email-finder, discover)' }, + phone_number: { type: 'string', description: 'Phone number' }, + company: { type: 'string', description: 'Company name (email-finder)' }, + sources: { + type: 'array', + description: + 'Source pages where the email was found (domain, uri, extracted_on, last_seen_on, still_on_page)', + }, + verification: { + type: 'json', + description: 'Email verification information (date, status)', + }, + // Email Verifier + result: { + type: 'string', + description: 'Deliverability result (deliverable, undeliverable, risky)', + }, + status: { + type: 'string', + description: 'Verification status (valid, invalid, accept_all, webmail, disposable, unknown)', + }, + regexp: { type: 'boolean', description: 'Email passes regex validation' }, + gibberish: { type: 'boolean', description: 'Whether email looks auto-generated' }, + mx_records: { type: 'boolean', description: 'MX records exist for the domain' }, + smtp_server: { type: 'boolean', description: 'SMTP server reachable' }, + smtp_check: { type: 'boolean', description: 'Email does not bounce' }, + block: { type: 'boolean', description: 'Whether the domain blocks verification' }, + // Discover + results: { + type: 'array', + description: + 'Companies matching the search (domain, organization, personal_emails, generic_emails, total_emails)', + }, + // Companies Find (flattened) + name: { type: 'string', description: 'Company name (companies-find, discover)' }, + description: { type: 'string', description: 'Company description' }, + industry: { type: 'string', description: 'Industry classification' }, + sector: { type: 'string', description: 'Business sector' }, + size: { type: 'string', description: 'Employee headcount range (e.g., "11-50")' }, + founded_year: { type: 'number', description: 'Year founded' }, + location: { type: 'string', description: 'Headquarters location (formatted)' }, + country: { type: 'string', description: 'Country (full name)' }, + country_code: { type: 'string', description: 'ISO 3166-1 alpha-2 country code' }, + state: { type: 'string', description: 'State/province' }, + city: { type: 'string', description: 'City' }, + linkedin: { type: 'string', description: 'LinkedIn handle (companies-find)' }, + twitter: { type: 'string', description: 'Twitter handle' }, + facebook: { type: 'string', description: 'Facebook handle' }, + logo: { type: 'string', description: 'Company logo URL' }, + phone: { type: 'string', description: 'Company phone number' }, + tech: { type: 'array', description: 'Technologies used by the company' }, + // Email Count + total: { type: 'number', description: 'Total email count' }, personal_emails: { type: 'number', description: 'Personal emails count' }, generic_emails: { type: 'number', description: 'Generic emails count' }, + department: { + type: 'json', + description: + 'Email count by department (executive, it, finance, management, sales, legal, support, hr, marketing, communication, education, design, health, operations)', + }, + seniority: { + type: 'json', + description: 'Email count by seniority level (junior, senior, executive)', + }, }, } diff --git a/apps/sim/tools/hunter/companies_find.ts b/apps/sim/tools/hunter/companies_find.ts index 1ba15585c8a..4b158a91f3a 100644 --- a/apps/sim/tools/hunter/companies_find.ts +++ b/apps/sim/tools/hunter/companies_find.ts @@ -1,5 +1,4 @@ import type { HunterEnrichmentParams, HunterEnrichmentResponse } from '@/tools/hunter/types' -import { COMPANY_OUTPUT } from '@/tools/hunter/types' import type { ToolConfig } from '@/tools/types' export const companiesFindTool: ToolConfig = { @@ -39,32 +38,57 @@ export const companiesFindTool: ToolConfig { const data = await response.json() + const c = data.data ?? {} return { success: true, output: { - person: undefined, - company: data.data - ? { - name: data.data.name || '', - domain: data.data.domain || '', - industry: data.data.industry || '', - size: data.data.size || '', - country: data.data.country || '', - linkedin: data.data.linkedin || '', - twitter: data.data.twitter || '', - } - : undefined, + name: c.name ?? '', + domain: c.domain ?? '', + description: c.description ?? '', + industry: c.category?.industry ?? '', + sector: c.category?.sector ?? '', + size: + c.metrics?.employeesRange ?? + (c.metrics?.employees != null ? String(c.metrics.employees) : ''), + founded_year: c.foundedYear ?? null, + location: c.location ?? '', + country: c.geo?.country ?? '', + country_code: c.geo?.countryCode ?? '', + state: c.geo?.state ?? '', + city: c.geo?.city ?? '', + linkedin: c.linkedin?.handle ?? '', + twitter: c.twitter?.handle ?? '', + facebook: c.facebook?.handle ?? '', + logo: c.logo ?? '', + phone: c.phone ?? '', + tech: c.tech ?? [], }, } }, outputs: { - person: { - type: 'object', - description: 'Person information (undefined for companies_find tool)', - optional: true, + name: { type: 'string', description: 'Company name' }, + domain: { type: 'string', description: 'Company domain' }, + description: { type: 'string', description: 'Company description' }, + industry: { type: 'string', description: 'Industry classification' }, + sector: { type: 'string', description: 'Business sector' }, + size: { type: 'string', description: 'Employee headcount range (e.g., "11-50")' }, + founded_year: { type: 'number', description: 'Year founded', optional: true }, + location: { type: 'string', description: 'Headquarters location (formatted)' }, + country: { type: 'string', description: 'Country (full name)' }, + country_code: { type: 'string', description: 'ISO 3166-1 alpha-2 country code' }, + state: { type: 'string', description: 'State/province' }, + city: { type: 'string', description: 'City' }, + linkedin: { type: 'string', description: 'LinkedIn handle (e.g., company/hunterio)' }, + twitter: { type: 'string', description: 'Twitter handle' }, + facebook: { type: 'string', description: 'Facebook handle' }, + logo: { type: 'string', description: 'Company logo URL' }, + phone: { type: 'string', description: 'Company phone number' }, + tech: { + type: 'array', + description: 'Technologies used by the company', + items: { type: 'string', description: 'Technology name' }, }, - company: COMPANY_OUTPUT, }, } diff --git a/apps/sim/tools/hunter/discover.ts b/apps/sim/tools/hunter/discover.ts index 1be006fc2e5..bdcd26c97da 100644 --- a/apps/sim/tools/hunter/discover.ts +++ b/apps/sim/tools/hunter/discover.ts @@ -71,13 +71,12 @@ export const discoverTool: ToolConfig { - const body: Record = {} + const body: Record = {} - // Add optional parameters if provided if (params.query) body.query = params.query if (params.domain) body.organization = { domain: [params.domain] } - if (params.headcount) body.headcount = params.headcount - if (params.company_type) body.company_type = params.company_type + if (params.headcount) body.headcount = [params.headcount] + if (params.company_type) body.company_type = { include: [params.company_type] } if (params.technology) { body.technology = { include: [params.technology], @@ -90,18 +89,22 @@ export const discoverTool: ToolConfig { const data = await response.json() + const companies: Array<{ + domain?: string + organization?: string + emails_count?: { personal?: number; generic?: number; total?: number } + }> = Array.isArray(data?.data) ? data.data : [] return { success: true, output: { - results: - data.data?.map((company: any) => ({ - domain: company.domain || '', - name: company.organization || '', - headcount: company.headcount, - technologies: company.technologies || [], - email_count: company.emails_count?.total || 0, - })) || [], + results: companies.map((c) => ({ + domain: c.domain ?? '', + organization: c.organization ?? '', + personal_emails: c.emails_count?.personal ?? 0, + generic_emails: c.emails_count?.generic ?? 0, + total_emails: c.emails_count?.total ?? 0, + })), }, } }, diff --git a/apps/sim/tools/hunter/domain_search.ts b/apps/sim/tools/hunter/domain_search.ts index 5f031e49271..33d99cbd508 100644 --- a/apps/sim/tools/hunter/domain_search.ts +++ b/apps/sim/tools/hunter/domain_search.ts @@ -1,4 +1,8 @@ -import type { HunterDomainSearchParams, HunterDomainSearchResponse } from '@/tools/hunter/types' +import type { + HunterDomainSearchParams, + HunterDomainSearchResponse, + HunterEmail, +} from '@/tools/hunter/types' import { EMAILS_OUTPUT } from '@/tools/hunter/types' import type { ToolConfig } from '@/tools/types' @@ -77,45 +81,35 @@ export const domainSearchTool: ToolConfig { const data = await response.json() + const d = data.data ?? {} return { success: true, output: { - domain: data.data?.domain || '', - disposable: data.data?.disposable || false, - webmail: data.data?.webmail || false, - accept_all: data.data?.accept_all || false, - pattern: data.data?.pattern || '', - organization: data.data?.organization || '', - description: data.data?.description || '', - industry: data.data?.industry || '', - twitter: data.data?.twitter || '', - facebook: data.data?.facebook || '', - linkedin: data.data?.linkedin || '', - instagram: data.data?.instagram || '', - youtube: data.data?.youtube || '', - technologies: data.data?.technologies || [], - country: data.data?.country || '', - state: data.data?.state || '', - city: data.data?.city || '', - postal_code: data.data?.postal_code || '', - street: data.data?.street || '', + domain: d.domain ?? '', + disposable: d.disposable ?? false, + webmail: d.webmail ?? false, + accept_all: d.accept_all ?? false, + pattern: d.pattern ?? '', + organization: d.organization ?? '', + linked_domains: d.linked_domains ?? [], emails: - data.data?.emails?.map((email: any) => ({ - value: email.value || '', - type: email.type || '', - confidence: email.confidence || 0, - sources: email.sources || [], - first_name: email.first_name || '', - last_name: email.last_name || '', - position: email.position || '', - seniority: email.seniority || '', - department: email.department || '', - linkedin: email.linkedin || '', - twitter: email.twitter || '', - phone_number: email.phone_number || '', - verification: email.verification || {}, - })) || [], + d.emails?.map((email: Partial) => ({ + value: email.value ?? '', + type: email.type ?? '', + confidence: email.confidence ?? 0, + sources: email.sources ?? [], + first_name: email.first_name ?? null, + last_name: email.last_name ?? null, + position: email.position ?? null, + position_raw: email.position_raw ?? null, + seniority: email.seniority ?? null, + department: email.department ?? null, + linkedin: email.linkedin ?? null, + twitter: email.twitter ?? null, + phone_number: email.phone_number ?? null, + verification: email.verification ?? { date: null, status: 'unknown' }, + })) ?? [], }, } }, @@ -145,61 +139,10 @@ export const domainSearchTool: ToolConfig { const data = await response.json() + const d = data.data ?? {} return { success: true, output: { - email: data.data?.email || '', - score: data.data?.score || 0, - sources: data.data?.sources || [], - verification: data.data?.verification || {}, + first_name: d.first_name ?? '', + last_name: d.last_name ?? '', + email: d.email ?? '', + score: d.score ?? 0, + domain: d.domain ?? '', + accept_all: d.accept_all ?? false, + position: d.position ?? null, + twitter: d.twitter ?? null, + linkedin_url: d.linkedin_url ?? null, + phone_number: d.phone_number ?? null, + company: d.company ?? null, + sources: d.sources ?? [], + verification: d.verification ?? { date: null, status: 'unknown' }, }, } }, outputs: { - email: { - type: 'string', - description: 'The found email address', - }, + first_name: { type: 'string', description: "Person's first name" }, + last_name: { type: 'string', description: "Person's last name" }, + email: { type: 'string', description: 'The found email address' }, score: { type: 'number', description: 'Confidence score (0-100) for the found email address', }, + domain: { type: 'string', description: 'Domain that was searched' }, + accept_all: { + type: 'boolean', + description: 'Whether the server accepts all email addresses (may cause false positives)', + }, + position: { type: 'string', description: 'Job title/position', optional: true }, + twitter: { type: 'string', description: 'Twitter handle', optional: true }, + linkedin_url: { type: 'string', description: 'LinkedIn profile URL', optional: true }, + phone_number: { type: 'string', description: 'Phone number', optional: true }, + company: { type: 'string', description: 'Company name', optional: true }, sources: SOURCES_OUTPUT, verification: VERIFICATION_OUTPUT, }, diff --git a/apps/sim/tools/hunter/hunter.test.ts b/apps/sim/tools/hunter/hunter.test.ts new file mode 100644 index 00000000000..f3b12d23a71 --- /dev/null +++ b/apps/sim/tools/hunter/hunter.test.ts @@ -0,0 +1,269 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { companiesFindTool } from '@/tools/hunter/companies_find' +import { discoverTool } from '@/tools/hunter/discover' +import { domainSearchTool } from '@/tools/hunter/domain_search' +import { emailFinderTool } from '@/tools/hunter/email_finder' + +const respond = (body: unknown) => new Response(JSON.stringify(body)) + +describe('hunter domain_search', () => { + const transform = domainSearchTool.transformResponse! + + it('maps the documented response shape', async () => { + const result = await transform( + respond({ + data: { + domain: 'stripe.com', + disposable: false, + webmail: false, + accept_all: true, + pattern: '{first}', + organization: 'Stripe', + linked_domains: ['stripe.io'], + emails: [ + { + value: 'patrick@stripe.com', + type: 'personal', + confidence: 92, + first_name: 'Patrick', + last_name: 'Collison', + position: 'CEO', + seniority: 'executive', + department: 'executive', + linkedin: null, + twitter: 'patrickc', + phone_number: null, + sources: [], + verification: { date: '2024-01-01', status: 'valid' }, + }, + ], + }, + }) + ) + + expect(result.success).toBe(true) + expect(result.output.domain).toBe('stripe.com') + expect(result.output.linked_domains).toEqual(['stripe.io']) + expect(result.output.emails).toHaveLength(1) + expect(result.output.emails[0]).toMatchObject({ + value: 'patrick@stripe.com', + first_name: 'Patrick', + twitter: 'patrickc', + verification: { status: 'valid' }, + }) + }) + + it('returns safe defaults when fields are missing', async () => { + const result = await transform(respond({ data: null })) + expect(result.output).toMatchObject({ + domain: '', + disposable: false, + webmail: false, + accept_all: false, + pattern: '', + organization: '', + linked_domains: [], + emails: [], + }) + }) + + it('nullifies missing optional email fields', async () => { + const result = await transform( + respond({ + data: { + emails: [{ value: 'a@b.com', type: 'generic', confidence: 50 }], + }, + }) + ) + expect(result.output.emails[0]).toMatchObject({ + first_name: null, + last_name: null, + position: null, + linkedin: null, + verification: { status: 'unknown' }, + }) + }) +}) + +describe('hunter email_finder', () => { + const transform = emailFinderTool.transformResponse! + + it('extracts the documented finder fields', async () => { + const result = await transform( + respond({ + data: { + first_name: 'Alex', + last_name: 'Smith', + email: 'alex@acme.com', + score: 85, + domain: 'acme.com', + accept_all: false, + position: 'Engineer', + twitter: null, + linkedin_url: 'https://linkedin.com/in/alex', + phone_number: null, + company: 'Acme', + sources: [], + verification: { date: null, status: 'valid' }, + }, + }) + ) + + expect(result.output).toMatchObject({ + first_name: 'Alex', + email: 'alex@acme.com', + score: 85, + linkedin_url: 'https://linkedin.com/in/alex', + company: 'Acme', + verification: { status: 'valid' }, + }) + }) + + it('falls back to safe defaults', async () => { + const result = await transform(respond({ data: {} })) + expect(result.output).toMatchObject({ + email: '', + score: 0, + accept_all: false, + sources: [], + verification: { date: null, status: 'unknown' }, + }) + }) +}) + +describe('hunter discover', () => { + const transform = discoverTool.transformResponse! + + it('maps documented data array shape', async () => { + const result = await transform( + respond({ + data: [ + { + domain: 'hunter.io', + organization: 'Hunter', + emails_count: { personal: 23, generic: 5, total: 28 }, + }, + ], + }) + ) + + expect(result.output.results).toEqual([ + { + domain: 'hunter.io', + organization: 'Hunter', + personal_emails: 23, + generic_emails: 5, + total_emails: 28, + }, + ]) + }) + + it('returns empty array when data is missing', async () => { + const result = await transform(respond({})) + expect(result.output.results).toEqual([]) + }) + + it('falls back to zero counts when emails_count is missing', async () => { + const result = await transform( + respond({ data: [{ domain: 'acme.com', organization: 'Acme' }] }) + ) + expect(result.output.results[0]).toEqual({ + domain: 'acme.com', + organization: 'Acme', + personal_emails: 0, + generic_emails: 0, + total_emails: 0, + }) + }) + + it('throws when no search params provided', () => { + const buildUrl = discoverTool.request.url as (p: Record) => string + expect(() => buildUrl({ apiKey: 'k' })).toThrow(/At least one search parameter/) + }) + + it('builds body per docs (headcount as plain array, technology wrapped)', () => { + const buildBody = discoverTool.request.body as ( + p: Record + ) => Record + const body = buildBody({ apiKey: 'k', headcount: '11-50', technology: 'react' }) + expect(body).toEqual({ + headcount: ['11-50'], + technology: { include: ['react'] }, + }) + }) +}) + +describe('hunter companies_find', () => { + const transform = companiesFindTool.transformResponse! + + it('flattens nested company fields', async () => { + const result = await transform( + respond({ + data: { + name: 'Stripe', + domain: 'stripe.com', + description: 'Payments', + category: { industry: 'Fintech', sector: 'Software' }, + metrics: { employees: '1000+' }, + foundedYear: 2010, + location: 'San Francisco, CA', + geo: { country: 'United States', countryCode: 'US', state: 'CA', city: 'SF' }, + linkedin: { handle: 'company/stripe' }, + twitter: { handle: 'stripe' }, + facebook: { handle: 'stripe' }, + logo: 'https://logo.png', + phone: '+1-555', + tech: ['react', 'node'], + }, + }) + ) + + expect(result.output).toEqual({ + name: 'Stripe', + domain: 'stripe.com', + description: 'Payments', + industry: 'Fintech', + sector: 'Software', + size: '1000+', + founded_year: 2010, + location: 'San Francisco, CA', + country: 'United States', + country_code: 'US', + state: 'CA', + city: 'SF', + linkedin: 'company/stripe', + twitter: 'stripe', + facebook: 'stripe', + logo: 'https://logo.png', + phone: '+1-555', + tech: ['react', 'node'], + }) + }) + + it('prefers employeesRange and coerces numeric employees', async () => { + const rangeResult = await transform( + respond({ data: { metrics: { employees: 5432, employeesRange: '1001-5000' } } }) + ) + expect(rangeResult.output.size).toBe('1001-5000') + + const numericResult = await transform(respond({ data: { metrics: { employees: 5432 } } })) + expect(numericResult.output.size).toBe('5432') + }) + + it('survives missing nested objects', async () => { + const result = await transform(respond({ data: {} })) + expect(result.output).toMatchObject({ + name: '', + industry: '', + sector: '', + size: '', + country: '', + linkedin: '', + tech: [], + founded_year: null, + }) + }) +}) diff --git a/apps/sim/tools/hunter/types.ts b/apps/sim/tools/hunter/types.ts index 4751925a3d7..a55cb6a2795 100644 --- a/apps/sim/tools/hunter/types.ts +++ b/apps/sim/tools/hunter/types.ts @@ -71,14 +71,20 @@ export const EMAIL_OUTPUT_PROPERTIES = { type: 'number', description: 'Probability score (0-100) that the email is correct', }, - first_name: { type: 'string', description: "Person's first name" }, - last_name: { type: 'string', description: "Person's last name" }, - position: { type: 'string', description: 'Job title/position' }, - seniority: { type: 'string', description: 'Seniority level (junior, senior, executive)' }, + first_name: { type: 'string', description: "Person's first name", optional: true }, + last_name: { type: 'string', description: "Person's last name", optional: true }, + position: { type: 'string', description: 'Job title/position', optional: true }, + position_raw: { type: 'string', description: 'Raw job title as found', optional: true }, + seniority: { + type: 'string', + description: 'Seniority level (junior, senior, executive)', + optional: true, + }, department: { type: 'string', description: - 'Department (executive, it, finance, management, sales, legal, support, hr, marketing, communication)', + 'Department (executive, it, finance, management, sales, legal, support, hr, marketing, communication, education, design, health, operations)', + optional: true, }, linkedin: { type: 'string', description: 'LinkedIn profile URL', optional: true }, twitter: { type: 'string', description: 'Twitter handle', optional: true }, @@ -147,36 +153,16 @@ export const SENIORITY_OUTPUT: OutputProperty = { } /** - * Output definition for emails_count object in discover results - */ -export const EMAILS_COUNT_OUTPUT_PROPERTIES = { - personal: { type: 'number', description: 'Number of personal email addresses' }, - generic: { type: 'number', description: 'Number of generic/role-based email addresses' }, - total: { type: 'number', description: 'Total number of email addresses' }, -} as const satisfies Record - -/** - * Complete emails_count object output definition - */ -export const EMAILS_COUNT_OUTPUT: OutputProperty = { - type: 'object', - description: 'Email count breakdown', - properties: EMAILS_COUNT_OUTPUT_PROPERTIES, -} - -/** - * Output definition for discover result company objects + * Output definition for discover result company objects. + * Hunter Discover returns minimal info per company — use Domain Search or + * Company Enrichment for richer data on a specific result. */ export const DISCOVER_RESULT_OUTPUT_PROPERTIES = { domain: { type: 'string', description: 'Company domain' }, - name: { type: 'string', description: 'Company/organization name' }, - headcount: { type: 'number', description: 'Company size/headcount', optional: true }, - technologies: { - type: 'array', - description: 'Technologies used by the company', - items: { type: 'string', description: 'Technology name' }, - }, - email_count: { type: 'number', description: 'Total number of email addresses found' }, + organization: { type: 'string', description: 'Organization name' }, + personal_emails: { type: 'number', description: 'Count of personal emails' }, + generic_emails: { type: 'number', description: 'Count of generic (role-based) emails' }, + total_emails: { type: 'number', description: 'Total emails found for the company' }, } as const satisfies Record /** @@ -191,28 +177,6 @@ export const DISCOVER_RESULTS_OUTPUT: OutputProperty = { }, } -/** - * Output definition for company enrichment objects - */ -export const COMPANY_OUTPUT_PROPERTIES = { - name: { type: 'string', description: 'Company name' }, - domain: { type: 'string', description: 'Company domain' }, - industry: { type: 'string', description: 'Industry classification' }, - size: { type: 'string', description: 'Company size/headcount range' }, - country: { type: 'string', description: 'Country where the company is located' }, - linkedin: { type: 'string', description: 'LinkedIn company page URL', optional: true }, - twitter: { type: 'string', description: 'Twitter handle', optional: true }, -} as const satisfies Record - -/** - * Complete company object output definition - */ -export const COMPANY_OUTPUT: OutputProperty = { - type: 'object', - description: 'Company information', - properties: COMPANY_OUTPUT_PROPERTIES, -} - // Common parameters for all Hunter.io tools export interface HunterBaseParams { apiKey: string @@ -229,10 +193,10 @@ export interface HunterDiscoverParams extends HunterBaseParams { export interface HunterDiscoverResult { domain: string - name: string - headcount?: number - technologies?: string[] - email_count?: number + organization: string + personal_emails: number + generic_emails: number + total_emails: number } export interface HunterDiscoverResponse extends ToolResponse { @@ -262,16 +226,17 @@ export interface HunterEmail { last_seen_on: string still_on_page: boolean }> - first_name: string - last_name: string - position: string - seniority: string - department: string - linkedin: string - twitter: string - phone_number: string + first_name: string | null + last_name: string | null + position: string | null + position_raw: string | null + seniority: string | null + department: string | null + linkedin: string | null + twitter: string | null + phone_number: string | null verification: { - date: string + date: string | null status: string } } @@ -284,19 +249,7 @@ export interface HunterDomainSearchResponse extends ToolResponse { accept_all: boolean pattern: string organization: string - description: string - industry: string - twitter: string - facebook: string - linkedin: string - instagram: string - youtube: string - technologies: string[] - country: string - state: string - city: string - postal_code: string - street: string + linked_domains: string[] emails: HunterEmail[] } } @@ -311,8 +264,17 @@ export interface HunterEmailFinderParams extends HunterBaseParams { export interface HunterEmailFinderResponse extends ToolResponse { output: { + first_name: string + last_name: string email: string score: number + domain: string + accept_all: boolean + position: string | null + twitter: string | null + linkedin_url: string | null + phone_number: string | null + company: string | null sources: Array<{ domain: string uri: string @@ -321,7 +283,7 @@ export interface HunterEmailFinderResponse extends ToolResponse { still_on_page: boolean }> verification: { - date: string + date: string | null status: string } } @@ -366,26 +328,24 @@ export interface HunterEnrichmentParams extends HunterBaseParams { export interface HunterEnrichmentResponse extends ToolResponse { output: { - person?: { - first_name: string - last_name: string - email: string - position: string - seniority: string - department: string - linkedin: string - twitter: string - phone_number: string - } - company?: { - name: string - domain: string - industry: string - size: string - country: string - linkedin: string - twitter: string - } + name: string + domain: string + description: string + industry: string + sector: string + size: string + founded_year: number | null + location: string + country: string + country_code: string + state: string + city: string + linkedin: string + twitter: string + facebook: string + logo: string + phone: string + tech: string[] } }