From 6c8bfa3cae27d178232e5e952a9b7cbb8a433a56 Mon Sep 17 00:00:00 2001 From: Ben Sabic Date: Wed, 3 Dec 2025 11:44:22 +1100 Subject: [PATCH 1/2] feat: add Hunter plugin --- plugins/hunter/credentials.ts | 3 + plugins/hunter/icon.tsx | 14 ++ plugins/hunter/index.ts | 69 +++++++ plugins/hunter/steps/enrich-lead.ts | 307 ++++++++++++++++++++++++++++ plugins/hunter/test.ts | 39 ++++ 5 files changed, 432 insertions(+) create mode 100644 plugins/hunter/credentials.ts create mode 100644 plugins/hunter/icon.tsx create mode 100644 plugins/hunter/index.ts create mode 100644 plugins/hunter/steps/enrich-lead.ts create mode 100644 plugins/hunter/test.ts diff --git a/plugins/hunter/credentials.ts b/plugins/hunter/credentials.ts new file mode 100644 index 00000000..842616b6 --- /dev/null +++ b/plugins/hunter/credentials.ts @@ -0,0 +1,3 @@ +export type HunterCredentials = { + HUNTER_API_KEY?: string; +}; diff --git a/plugins/hunter/icon.tsx b/plugins/hunter/icon.tsx new file mode 100644 index 00000000..57665d58 --- /dev/null +++ b/plugins/hunter/icon.tsx @@ -0,0 +1,14 @@ +export function HunterIcon({ className }: { className?: string }) { + return ( + + Hunter + + + ); +} diff --git a/plugins/hunter/index.ts b/plugins/hunter/index.ts new file mode 100644 index 00000000..1be00c8f --- /dev/null +++ b/plugins/hunter/index.ts @@ -0,0 +1,69 @@ +import type { IntegrationPlugin } from "../registry"; +import { registerIntegration } from "../registry"; +import { HunterIcon } from "./icon"; + +const hunterPlugin: IntegrationPlugin = { + type: "hunter", + label: "Hunter", + description: "All-in-one email outreach platform", + icon: HunterIcon, + + formFields: [ + { + id: "apiKey", + label: "API Key", + type: "password", + placeholder: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + configKey: "apiKey", + envVar: "HUNTER_API_KEY", + helpText: "Get your API key from ", + helpLink: { + text: "hunter.io/api-keys", + url: "https://hunter.io/api-keys", + }, + }, + ], + + testConfig: { + getTestFunction: async () => { + const { testHunter } = await import("./test"); + return testHunter; + }, + }, + + actions: [ + { + slug: "enrich-lead", + label: "Enrich Lead", + description: "Get data on a lead and their company", + category: "Hunter", + stepFunction: "enrichLeadStep", + stepImportPath: "enrich-lead", + configFields: [ + { + key: "email", + label: "Lead Email", + type: "template-input", + placeholder: "{{Trigger.email}} or john@example.com", + example: "john@example.com", + required: true, + }, + { + key: "enrichmentType", + label: "Enrichment Type", + type: "select", + options: [ + { value: "individual", label: "Individual" }, + { value: "company", label: "Company" }, + { value: "combined", label: "Combined" }, + ], + defaultValue: "combined", + }, + ], + }, + ], +}; + +registerIntegration(hunterPlugin); + +export default hunterPlugin; diff --git a/plugins/hunter/steps/enrich-lead.ts b/plugins/hunter/steps/enrich-lead.ts new file mode 100644 index 00000000..6a607ae0 --- /dev/null +++ b/plugins/hunter/steps/enrich-lead.ts @@ -0,0 +1,307 @@ +import "server-only"; + +import { fetchCredentials } from "@/lib/credential-fetcher"; +import { type StepInput, withStepLogging } from "@/lib/steps/step-handler"; +import { getErrorMessage } from "@/lib/utils"; +import type { HunterCredentials } from "../credentials"; + +type PersonData = { + fullName?: string; + givenName?: string; + familyName?: string; + email?: string; + location?: string; + timezone?: string; + title?: string; + role?: string; + seniority?: string; + domain?: string; + company?: string; + twitter?: string; + linkedin?: string; + phone?: string; +}; + +type CompanyData = { + domain?: string; + name?: string; + headcount?: string; + industry?: string; + country?: string; + state?: string; + city?: string; + twitter?: string; + linkedin?: string; + facebook?: string; +}; + +type EnrichLeadResult = + | { + success: true; + enrichmentType: string; + person?: PersonData; + company?: CompanyData; + } + | { success: false; error: string }; + +export type EnrichLeadCoreInput = { + email: string; + enrichmentType?: "individual" | "company" | "combined"; +}; + +function isValidEmail(email: string): boolean { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); +} + +export type EnrichLeadInput = StepInput & + EnrichLeadCoreInput & { + integrationId?: string; + }; + +async function fetchPersonData( + email: string, + apiKey: string +): Promise { + const url = new URL("https://api.hunter.io/v2/people/find"); + url.searchParams.set("email", email); + url.searchParams.set("api_key", apiKey); + + const response = await fetch(url.toString(), { + method: "GET", + headers: { "Content-Type": "application/json" }, + }); + + if (!response.ok) { + if (response.status === 404) { + return null; + } + const error = await response.json().catch(() => ({})); + throw new Error( + error.errors?.[0]?.details || `HTTP ${response.status}: ${response.statusText}` + ); + } + + const result = await response.json(); + const data = result.data; + + if (!data) { + return null; + } + + return { + fullName: data.name?.fullName, + givenName: data.name?.givenName, + familyName: data.name?.familyName, + email: data.email, + location: data.location, + timezone: data.timeZone, + title: data.employment?.title, + role: data.employment?.role, + seniority: data.employment?.seniority, + domain: data.employment?.domain, + company: data.employment?.name, + twitter: data.twitter?.handle, + linkedin: data.linkedin?.handle, + phone: data.phone, + }; +} + +async function fetchCompanyData( + domain: string, + apiKey: string +): Promise { + const url = new URL("https://api.hunter.io/v2/companies/find"); + url.searchParams.set("domain", domain); + url.searchParams.set("api_key", apiKey); + + const response = await fetch(url.toString(), { + method: "GET", + headers: { "Content-Type": "application/json" }, + }); + + if (!response.ok) { + if (response.status === 404) { + return null; + } + const error = await response.json().catch(() => ({})); + throw new Error( + error.errors?.[0]?.details || `HTTP ${response.status}: ${response.statusText}` + ); + } + + const result = await response.json(); + const data = result.data; + + if (!data) { + return null; + } + + return { + domain: data.domain, + name: data.name, + headcount: data.headcount, + industry: data.industry, + country: data.country, + state: data.state, + city: data.city, + twitter: data.twitter, + linkedin: data.linkedin, + facebook: data.facebook, + }; +} + +async function fetchCombinedData( + email: string, + apiKey: string +): Promise<{ person: PersonData | null; company: CompanyData | null }> { + const url = new URL("https://api.hunter.io/v2/combined/find"); + url.searchParams.set("email", email); + url.searchParams.set("api_key", apiKey); + + const response = await fetch(url.toString(), { + method: "GET", + headers: { "Content-Type": "application/json" }, + }); + + if (!response.ok) { + if (response.status === 404) { + return { person: null, company: null }; + } + const error = await response.json().catch(() => ({})); + throw new Error( + error.errors?.[0]?.details || `HTTP ${response.status}: ${response.statusText}` + ); + } + + const result = await response.json(); + const data = result.data; + + if (!data) { + return { person: null, company: null }; + } + + const person: PersonData | null = data.person + ? { + fullName: data.person.name?.fullName, + givenName: data.person.name?.givenName, + familyName: data.person.name?.familyName, + email: data.person.email, + location: data.person.location, + timezone: data.person.timeZone, + title: data.person.employment?.title, + role: data.person.employment?.role, + seniority: data.person.employment?.seniority, + domain: data.person.employment?.domain, + company: data.person.employment?.name, + twitter: data.person.twitter?.handle, + linkedin: data.person.linkedin?.handle, + phone: data.person.phone, + } + : null; + + const company: CompanyData | null = data.company + ? { + domain: data.company.domain, + name: data.company.name, + headcount: data.company.headcount, + industry: data.company.industry, + country: data.company.country, + state: data.company.state, + city: data.company.city, + twitter: data.company.twitter, + linkedin: data.company.linkedin, + facebook: data.company.facebook, + } + : null; + + return { person, company }; +} + +function extractDomain(email: string): string | null { + const parts = email.split("@"); + if (parts.length !== 2) { + return null; + } + return parts[1]; +} + +async function stepHandler( + input: EnrichLeadCoreInput, + credentials: HunterCredentials +): Promise { + const apiKey = credentials.HUNTER_API_KEY; + + if (!apiKey) { + return { + success: false, + error: + "HUNTER_API_KEY is not configured. Please add it in Project Integrations.", + }; + } + + const enrichmentType = input.enrichmentType || "combined"; + const email = input.email.trim(); + + if (!isValidEmail(email)) { + return { + success: false, + error: "Invalid email address format", + }; + } + + try { + let person: PersonData | null = null; + let company: CompanyData | null = null; + + if (enrichmentType === "individual") { + person = await fetchPersonData(email, apiKey); + } else if (enrichmentType === "company") { + const domain = extractDomain(email); + if (!domain) { + return { + success: false, + error: "Invalid email format - could not extract domain", + }; + } + company = await fetchCompanyData(domain, apiKey); + } else { + const result = await fetchCombinedData(email, apiKey); + person = result.person; + company = result.company; + } + + if (!person && !company) { + return { + success: false, + error: "No enrichment data found for the provided identifier", + }; + } + + return { + success: true, + enrichmentType, + ...(person && { person }), + ...(company && { company }), + }; + } catch (error) { + return { + success: false, + error: `Failed to enrich lead: ${getErrorMessage(error)}`, + }; + } +} + +export async function enrichLeadStep( + input: EnrichLeadInput +): Promise { + "use step"; + + const credentials = input.integrationId + ? await fetchCredentials(input.integrationId) + : {}; + + return withStepLogging(input, () => stepHandler(input, credentials)); +} + +export const _integrationType = "hunter"; diff --git a/plugins/hunter/test.ts b/plugins/hunter/test.ts new file mode 100644 index 00000000..85049d91 --- /dev/null +++ b/plugins/hunter/test.ts @@ -0,0 +1,39 @@ +export async function testHunter(credentials: Record) { + try { + const apiKey = credentials.HUNTER_API_KEY; + + if (!apiKey) { + return { + success: false, + error: "HUNTER_API_KEY is required", + }; + } + + const url = new URL("https://api.hunter.io/v2/account"); + url.searchParams.set("api_key", apiKey); + + const response = await fetch(url.toString(), { + method: "GET", + headers: { "Content-Type": "application/json" }, + }); + + if (response.ok) { + return { success: true }; + } + + if (response.status === 401) { + return { success: false, error: "Invalid API key" }; + } + + const error = await response.json().catch(() => ({})); + return { + success: false, + error: error.errors?.[0]?.details || `API error: HTTP ${response.status}`, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} From 47a901b7fd5a158b050ea5c153e301f50c90260c Mon Sep 17 00:00:00 2001 From: Ben Sabic Date: Fri, 5 Dec 2025 13:23:07 +1100 Subject: [PATCH 2/2] chore: add hunter plugin import --- plugins/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/index.ts b/plugins/index.ts index c2b41249..6001708d 100644 --- a/plugins/index.ts +++ b/plugins/index.ts @@ -19,6 +19,7 @@ import "./blob"; import "./fal"; import "./firecrawl"; import "./github"; +import "./hunter"; import "./linear"; import "./perplexity"; import "./resend";