diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx index 99f25b48bf6..8d4b5e527fd 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx @@ -6,6 +6,9 @@ import { AnthropicIcon, BasetenIcon, BrandfetchIcon, + DatagmaIcon, + DropcontactIcon, + EnrowIcon, ExaAIIcon, FalIcon, FindymailIcon, @@ -14,7 +17,9 @@ import { GeminiIcon, GoogleIcon, HunterIOIcon, + IcypeasIcon, JinaAIIcon, + LeadMagicIcon, LinkupIcon, MillionVerifierIcon, MistralIcon, @@ -202,6 +207,41 @@ const PROVIDERS: (BYOKManagerProvider & { id: BYOKProviderId })[] = [ description: 'Prospect search, individual reveal, and company enrichment', placeholder: 'Enter your Wiza API key', }, + { + id: 'datagma', + name: 'Datagma', + icon: DatagmaIcon, + description: 'Email, phone, person, and company enrichment', + placeholder: 'Enter your Datagma API key', + }, + { + id: 'dropcontact', + name: 'Dropcontact', + icon: DropcontactIcon, + description: 'GDPR-compliant contact enrichment and email finding', + placeholder: 'Enter your Dropcontact API key', + }, + { + id: 'leadmagic', + name: 'LeadMagic', + icon: LeadMagicIcon, + description: 'Email finding, validation, and B2B profile enrichment', + placeholder: 'Enter your LeadMagic API key', + }, + { + id: 'icypeas', + name: 'Icypeas', + icon: IcypeasIcon, + description: 'Email finding and verification', + placeholder: 'Enter your Icypeas API key', + }, + { + id: 'enrow', + name: 'Enrow', + icon: EnrowIcon, + description: 'Email finding and verification', + placeholder: 'Enter your Enrow API key', + }, { id: 'zerobounce', name: 'ZeroBounce', @@ -267,6 +307,11 @@ const PROVIDER_SECTIONS: BYOKProviderSection[] = [ 'findymail', 'prospeo', 'wiza', + 'datagma', + 'dropcontact', + 'leadmagic', + 'icypeas', + 'enrow', 'zerobounce', 'neverbounce', 'millionverifier', diff --git a/apps/sim/blocks/blocks/datagma.ts b/apps/sim/blocks/blocks/datagma.ts new file mode 100644 index 00000000000..9abe2a5868e --- /dev/null +++ b/apps/sim/blocks/blocks/datagma.ts @@ -0,0 +1,325 @@ +import { DatagmaIcon } from '@/components/icons' +import { AuthMode, type BlockConfig, type BlockMeta, IntegrationType } from '@/blocks/types' +import type { DatagmaResponse } from '@/tools/datagma/types' + +export const DatagmaBlock: BlockConfig = { + type: 'datagma', + name: 'Datagma', + description: 'Find verified B2B emails, mobile phones, and enrich person or company profiles', + authMode: AuthMode.ApiKey, + longDescription: + 'Integrate Datagma to find verified work emails from a name and company, enrich person profiles via email or LinkedIn URL, enrich company data from a domain or name, look up mobile phone numbers from LinkedIn, and check your credit balance.', + docsLink: 'https://docs.sim.ai/tools/datagma', + category: 'tools', + integrationType: IntegrationType.Sales, + bgColor: '#FFFFFF', + icon: DatagmaIcon, + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Find Email', id: 'datagma_find_email' }, + { label: 'Enrich Person', id: 'datagma_enrich_person' }, + { label: 'Enrich Company', id: 'datagma_enrich_company' }, + { label: 'Find Phone', id: 'datagma_find_phone' }, + { label: 'Get Remaining Credits', id: 'datagma_get_credits' }, + ], + value: () => 'datagma_find_email', + }, + + // ------------------------------------------------------------------------- + // Find Email + // ------------------------------------------------------------------------- + { + id: 'fe_fullName', + title: 'Full Name', + type: 'short-input', + required: true, + placeholder: 'John Doe', + condition: { field: 'operation', value: 'datagma_find_email' }, + }, + { + id: 'fe_company', + title: 'Company Name or Domain', + type: 'short-input', + required: true, + placeholder: 'stripe.com', + condition: { field: 'operation', value: 'datagma_find_email' }, + }, + { + id: 'fe_linkedInSlug', + title: 'LinkedIn Company Slug', + type: 'short-input', + placeholder: 'https://linkedin.com/company/stripe', + condition: { field: 'operation', value: 'datagma_find_email' }, + mode: 'advanced', + }, + + // ------------------------------------------------------------------------- + // Enrich Person + // ------------------------------------------------------------------------- + { + id: 'ep_data', + title: 'Email, LinkedIn URL, or Full Name', + type: 'short-input', + required: true, + placeholder: 'john@stripe.com or https://linkedin.com/in/johndoe or John Doe', + condition: { field: 'operation', value: 'datagma_enrich_person' }, + }, + { + id: 'ep_companyKeyword', + title: 'Company (when using full name)', + type: 'short-input', + placeholder: 'Stripe', + condition: { field: 'operation', value: 'datagma_enrich_person' }, + }, + { + id: 'ep_phoneFull', + title: 'Find Phone Number', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes (costs 30 extra credits if found)', id: 'true' }, + ], + value: () => 'false', + condition: { field: 'operation', value: 'datagma_enrich_person' }, + }, + { + id: 'ep_countryCode', + title: 'Country Code', + type: 'short-input', + placeholder: 'US', + condition: { field: 'operation', value: 'datagma_enrich_person' }, + mode: 'advanced', + }, + { + id: 'ep_personFull', + title: 'Include Full Profile', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes (education + work history)', id: 'true' }, + ], + value: () => 'false', + condition: { field: 'operation', value: 'datagma_enrich_person' }, + mode: 'advanced', + }, + + // ------------------------------------------------------------------------- + // Enrich Company + // ------------------------------------------------------------------------- + { + id: 'ec_data', + title: 'Company Domain, Name, or SIREN', + type: 'short-input', + required: true, + placeholder: 'stripe.com', + condition: { field: 'operation', value: 'datagma_enrich_company' }, + }, + { + id: 'ec_companyPremium', + title: 'Include LinkedIn Data', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + value: () => 'false', + condition: { field: 'operation', value: 'datagma_enrich_company' }, + mode: 'advanced', + }, + { + id: 'ec_companyFull', + title: 'Include Financial Data', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + value: () => 'false', + condition: { field: 'operation', value: 'datagma_enrich_company' }, + mode: 'advanced', + }, + + // ------------------------------------------------------------------------- + // Find Phone + // ------------------------------------------------------------------------- + { + id: 'fp_username', + title: 'LinkedIn URL', + type: 'short-input', + required: true, + placeholder: 'https://linkedin.com/in/johndoe', + condition: { field: 'operation', value: 'datagma_find_phone' }, + }, + { + id: 'fp_email', + title: 'Email (improves accuracy)', + type: 'short-input', + placeholder: 'john@stripe.com', + condition: { field: 'operation', value: 'datagma_find_phone' }, + }, + + // ------------------------------------------------------------------------- + // API Key — hidden on hosted Sim for operations with hosted-key support + // ------------------------------------------------------------------------- + { + id: 'apiKey', + title: 'API Key', + type: 'short-input', + required: true, + placeholder: 'Enter your Datagma API key', + password: true, + hideWhenHosted: true, + condition: { field: 'operation', value: 'datagma_get_credits', not: true }, + }, + // API Key — always required for the credit-balance lookup (no hosted key) + { + id: 'apiKey', + title: 'API Key', + type: 'short-input', + required: true, + placeholder: 'Enter your Datagma API key', + password: true, + condition: { field: 'operation', value: 'datagma_get_credits' }, + }, + ], + + tools: { + access: [ + 'datagma_find_email', + 'datagma_enrich_person', + 'datagma_enrich_company', + 'datagma_find_phone', + 'datagma_get_credits', + ], + config: { + tool: (params) => { + switch (params.operation) { + case 'datagma_find_email': + case 'datagma_enrich_person': + case 'datagma_enrich_company': + case 'datagma_find_phone': + case 'datagma_get_credits': + return params.operation + default: + return 'datagma_find_email' + } + }, + params: (params) => { + const { operation: _operation, ...rest } = params + + // Map unique subBlock IDs back to tool param names + const idToParam: Record = { + fe_fullName: 'fullName', + fe_company: 'company', + fe_linkedInSlug: 'linkedInSlug', + ep_data: 'data', + ep_companyKeyword: 'companyKeyword', + ep_phoneFull: 'phoneFull', + ep_countryCode: 'countryCode', + ep_personFull: 'personFull', + ec_data: 'data', + ec_companyPremium: 'companyPremium', + ec_companyFull: 'companyFull', + fp_username: 'username', + fp_email: 'email', + } + + const result: Record = {} + for (const [key, value] of Object.entries(rest)) { + if (value === undefined || value === null || value === '') continue + const mappedKey = idToParam[key] ?? key + + // Coerce boolean-like dropdown values at execution time + if ( + mappedKey === 'phoneFull' || + mappedKey === 'personFull' || + mappedKey === 'companyPremium' || + mappedKey === 'companyFull' + ) { + result[mappedKey] = value === true || value === 'true' + } else { + result[mappedKey] = value + } + } + return result + }, + }, + }, + + inputs: { + operation: { type: 'string', description: 'Operation to perform' }, + apiKey: { type: 'string', description: 'Datagma API key' }, + // Find Email + fe_fullName: { type: 'string', description: "Person's full name (find email)" }, + fe_company: { type: 'string', description: 'Company name or domain (find email)' }, + fe_linkedInSlug: { type: 'string', description: 'LinkedIn company slug (find email)' }, + // Enrich Person + ep_data: { + type: 'string', + description: 'Email, LinkedIn URL, or full name (enrich person)', + }, + ep_companyKeyword: { type: 'string', description: 'Company keyword (enrich person)' }, + ep_phoneFull: { type: 'boolean', description: 'Find phone number (enrich person)' }, + ep_countryCode: { type: 'string', description: 'Country code (enrich person)' }, + ep_personFull: { type: 'boolean', description: 'Include full profile (enrich person)' }, + // Enrich Company + ec_data: { type: 'string', description: 'Company domain, name, or SIREN (enrich company)' }, + ec_companyPremium: { type: 'boolean', description: 'Include LinkedIn data (enrich company)' }, + ec_companyFull: { type: 'boolean', description: 'Include financial data (enrich company)' }, + // Find Phone + fp_username: { type: 'string', description: 'LinkedIn URL (find phone)' }, + fp_email: { type: 'string', description: 'Email address (find phone)' }, + }, + + outputs: { + // Find Email + email: { type: 'string', description: 'Verified work email address' }, + emailStatus: { type: 'string', description: 'Email verification status' }, + emailDomain: { type: 'string', description: 'Email domain' }, + mxfound: { type: 'boolean', description: 'Whether MX records were found' }, + smtpCheck: { type: 'boolean', description: 'Whether SMTP validation succeeded' }, + catchAll: { type: 'boolean', description: 'Whether the domain is catch-all' }, + // Enrich Person + name: { type: 'string', description: 'Full name' }, + firstName: { type: 'string', description: 'First name' }, + lastName: { type: 'string', description: 'Last name' }, + jobTitle: { type: 'string', description: 'Current job title' }, + company: { type: 'string', description: 'Current company name' }, + linkedInUrl: { type: 'string', description: 'LinkedIn profile URL' }, + location: { type: 'string', description: 'Location string' }, + country: { type: 'string', description: 'Country' }, + region: { type: 'string', description: 'Region/state' }, + city: { type: 'string', description: 'City' }, + extractedRole: { type: 'string', description: 'Extracted role category' }, + extractedSeniority: { type: 'string', description: 'Extracted seniority level' }, + twitter: { type: 'string', description: 'Twitter handle' }, + personConfidenceScore: { + type: 'number', + description: 'Confidence score for the person match', + }, + // Enrich Company + website: { type: 'string', description: 'Company website' }, + industries: { type: 'string', description: 'Industry classification' }, + companySize: { type: 'string', description: 'Employee headcount range' }, + type: { type: 'string', description: 'Company type (e.g., Private, Public)' }, + founded: { type: 'string', description: 'Year founded' }, + shortDescription: { type: 'string', description: 'Short company description' }, + revenueRange: { type: 'string', description: 'Estimated annual revenue range' }, + headquarters: { type: 'string', description: 'Headquarters location' }, + // Find Phone + phone: { type: 'string', description: 'Mobile phone number' }, + countryCode: { type: 'string', description: 'Country code prefix' }, + isWhatsapp: { type: 'boolean', description: 'Whether the number is linked to WhatsApp' }, + // Get Credits + credits: { type: 'number', description: 'Remaining Datagma credits' }, + }, +} + +export const DatagmaBlockMeta = { + tags: ['enrichment', 'sales-engagement'], + url: 'https://datagma.com', +} as const satisfies BlockMeta diff --git a/apps/sim/blocks/blocks/dropcontact.ts b/apps/sim/blocks/blocks/dropcontact.ts new file mode 100644 index 00000000000..be4ca04a2f2 --- /dev/null +++ b/apps/sim/blocks/blocks/dropcontact.ts @@ -0,0 +1,224 @@ +import { DropcontactIcon } from '@/components/icons' +import { AuthMode, type BlockConfig, type BlockMeta, IntegrationType } from '@/blocks/types' +import type { DropcontactResponse } from '@/tools/dropcontact/types' + +export const DropcontactBlock: BlockConfig = { + type: 'dropcontact', + name: 'Dropcontact', + description: 'Enrich B2B contacts with verified email, phone, and company data', + longDescription: + 'Use Dropcontact to verify and enrich B2B contacts. Submit a contact with their name, company, website, or LinkedIn URL and receive a verified professional email, phone number, company firmographics, and LinkedIn profile. Enrichment is async: Dropcontact processes the request, then Sim polls until the result is ready. Credits are only charged when a verified email is returned.', + docsLink: 'https://docs.sim.ai/tools/dropcontact', + category: 'tools', + bgColor: '#0066FF', + icon: DropcontactIcon, + authMode: AuthMode.ApiKey, + integrationType: IntegrationType.Sales, + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [{ label: 'Enrich Contact', id: 'dropcontact_enrich_contact' }], + value: () => 'dropcontact_enrich_contact', + }, + + // Enrich Contact fields + { + id: 'email', + title: 'Email', + type: 'short-input', + placeholder: 'john.doe@acme.com', + condition: { field: 'operation', value: 'dropcontact_enrich_contact' }, + }, + { + id: 'first_name', + title: 'First Name', + type: 'short-input', + placeholder: 'John', + condition: { field: 'operation', value: 'dropcontact_enrich_contact' }, + }, + { + id: 'last_name', + title: 'Last Name', + type: 'short-input', + placeholder: 'Doe', + condition: { field: 'operation', value: 'dropcontact_enrich_contact' }, + }, + { + id: 'full_name', + title: 'Full Name', + type: 'short-input', + placeholder: 'John Doe (alternative to first + last name)', + condition: { field: 'operation', value: 'dropcontact_enrich_contact' }, + mode: 'advanced', + }, + { + id: 'company', + title: 'Company Name', + type: 'short-input', + placeholder: 'Acme Corp', + condition: { field: 'operation', value: 'dropcontact_enrich_contact' }, + }, + { + id: 'website', + title: 'Company Website', + type: 'short-input', + placeholder: 'acme.com', + condition: { field: 'operation', value: 'dropcontact_enrich_contact' }, + }, + { + id: 'linkedin', + title: 'LinkedIn URL', + type: 'short-input', + placeholder: 'https://linkedin.com/in/johndoe', + condition: { field: 'operation', value: 'dropcontact_enrich_contact' }, + mode: 'advanced', + }, + { + id: 'num_siren', + title: 'SIREN Number', + type: 'short-input', + placeholder: 'French company SIREN (optional)', + condition: { field: 'operation', value: 'dropcontact_enrich_contact' }, + mode: 'advanced', + }, + { + id: 'phone', + title: 'Phone', + type: 'short-input', + placeholder: '+1 555 555 5555', + condition: { field: 'operation', value: 'dropcontact_enrich_contact' }, + mode: 'advanced', + }, + { + id: 'country', + title: 'Country Code', + type: 'short-input', + placeholder: 'US', + condition: { field: 'operation', value: 'dropcontact_enrich_contact' }, + mode: 'advanced', + }, + { + id: 'siren', + title: 'Include SIREN Enrichment', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + value: () => 'false', + condition: { field: 'operation', value: 'dropcontact_enrich_contact' }, + mode: 'advanced', + }, + { + id: 'language', + title: 'Language', + type: 'short-input', + placeholder: 'en', + condition: { field: 'operation', value: 'dropcontact_enrich_contact' }, + mode: 'advanced', + }, + + // API Key — hidden on hosted Sim (hosted key handles it) + { + id: 'apiKey', + title: 'API Key', + type: 'short-input', + required: true, + placeholder: 'Enter your Dropcontact API key', + password: true, + hideWhenHosted: true, + }, + ], + tools: { + access: ['dropcontact_enrich_contact'], + config: { + tool: (_params) => 'dropcontact_enrich_contact', + params: (params) => { + const { operation: _operation, ...rest } = params + const result: Record = {} + + for (const [key, value] of Object.entries(rest)) { + if (value === undefined || value === null || value === '') continue + if (key === 'siren') { + result[key] = value === true || value === 'true' + } else { + result[key] = value + } + } + return result + }, + }, + }, + inputs: { + operation: { type: 'string', description: 'Operation to perform' }, + apiKey: { type: 'string', description: 'Dropcontact API key' }, + email: { type: 'string', description: 'Contact email address' }, + first_name: { type: 'string', description: 'Contact first name' }, + last_name: { type: 'string', description: 'Contact last name' }, + full_name: { type: 'string', description: 'Contact full name' }, + company: { type: 'string', description: 'Company name' }, + website: { type: 'string', description: 'Company website' }, + linkedin: { type: 'string', description: 'LinkedIn profile URL' }, + num_siren: { type: 'string', description: 'French company SIREN number' }, + phone: { type: 'string', description: 'Phone number' }, + country: { type: 'string', description: 'Country code (ISO 3166-1 alpha-2)' }, + siren: { type: 'boolean', description: 'Include SIREN/SIRET enrichment (France only)' }, + language: { type: 'string', description: 'Language for returned data' }, + }, + outputs: { + request_id: { type: 'string', description: 'Dropcontact async request ID' }, + email_found: { type: 'boolean', description: 'Whether a verified email was found' }, + email: { type: 'string', description: 'Primary verified email address' }, + emails: { + type: 'array', + description: 'All email addresses returned (each with email and qualification)', + }, + qualification: { + type: 'string', + description: 'Email qualification (e.g. nominative@pro)', + }, + first_name: { type: 'string', description: 'First name' }, + last_name: { type: 'string', description: 'Last name' }, + full_name: { type: 'string', description: 'Full name' }, + civility: { type: 'string', description: 'Civility (Mr, Mrs, etc.)' }, + phone: { type: 'string', description: 'Phone number' }, + mobile_phone: { type: 'string', description: 'Mobile phone number' }, + company: { type: 'string', description: 'Company name' }, + website: { type: 'string', description: 'Company website' }, + company_linkedin: { type: 'string', description: 'Company LinkedIn URL' }, + linkedin: { type: 'string', description: 'Personal LinkedIn URL' }, + country: { type: 'string', description: 'Country code (ISO 3166-1 alpha-2)' }, + siren: { type: 'string', description: 'French SIREN number' }, + siret: { type: 'string', description: 'French SIRET number' }, + siret_address: { type: 'string', description: 'SIRET registered address' }, + siret_zip: { type: 'string', description: 'SIRET registered postal code' }, + siret_city: { type: 'string', description: 'SIRET registered city' }, + vat: { type: 'string', description: 'VAT number' }, + nb_employees: { type: 'string', description: 'Employee count range' }, + employee_count: { + type: 'number', + description: 'Exact employee count (Growth plan and above)', + }, + naf5_code: { type: 'string', description: 'NAF/APE code (France)' }, + naf5_des: { + type: 'string', + description: 'NAF/APE code description (France)', + }, + industry: { type: 'string', description: 'Industry classification' }, + job: { type: 'string', description: 'Job title' }, + job_level: { type: 'string', description: 'Job seniority level' }, + job_function: { type: 'string', description: 'Job function' }, + company_turnover: { + type: 'string', + description: 'Company revenue/turnover range', + }, + company_results: { type: 'string', description: 'Company net results' }, + }, +} + +export const DropcontactBlockMeta = { + tags: ['enrichment', 'sales-engagement'], + url: 'https://www.dropcontact.com', +} as const satisfies BlockMeta diff --git a/apps/sim/blocks/blocks/enrow.ts b/apps/sim/blocks/blocks/enrow.ts new file mode 100644 index 00000000000..7d49e030b5d --- /dev/null +++ b/apps/sim/blocks/blocks/enrow.ts @@ -0,0 +1,128 @@ +import { EnrowIcon } from '@/components/icons' +import { AuthMode, type BlockConfig, type BlockMeta, IntegrationType } from '@/blocks/types' +import type { EnrowResponse } from '@/tools/enrow/types' + +export const EnrowBlock: BlockConfig = { + type: 'enrow', + name: 'Enrow', + description: 'Find and verify B2B emails with triple-verified accuracy', + authMode: AuthMode.ApiKey, + longDescription: + 'Integrate Enrow to find verified B2B email addresses from a full name and company, or verify the deliverability of an existing email. Enrow performs deterministic verifications including catch-all emails — no additional verifier needed.', + docsLink: 'https://enrow.readme.io', + category: 'tools', + integrationType: IntegrationType.Sales, + bgColor: '#FFFFFF', + icon: EnrowIcon, + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Find Email', id: 'enrow_find_email' }, + { label: 'Verify Email', id: 'enrow_verify_email' }, + ], + value: () => 'enrow_find_email', + }, + + // --- Find Email --- + { + id: 'fullname', + title: 'Full Name', + type: 'short-input', + required: true, + placeholder: 'John Doe', + condition: { field: 'operation', value: 'enrow_find_email' }, + }, + { + id: 'company_domain', + title: 'Company Domain', + type: 'short-input', + required: true, + placeholder: 'stripe.com', + condition: { field: 'operation', value: 'enrow_find_email' }, + }, + { + id: 'company_name', + title: 'Company Name', + type: 'short-input', + placeholder: 'Stripe (used when domain is unavailable)', + condition: { field: 'operation', value: 'enrow_find_email' }, + mode: 'advanced', + }, + + // --- Verify Email --- + { + id: 've_email', + title: 'Email Address', + type: 'short-input', + required: true, + placeholder: 'john@example.com', + condition: { field: 'operation', value: 'enrow_verify_email' }, + }, + + // --- API Key (hidden on hosted Sim for operations with hosted-key support) --- + { + id: 'apiKey', + title: 'API Key', + type: 'short-input', + required: true, + placeholder: 'Enter your Enrow API key', + password: true, + hideWhenHosted: true, + }, + ], + tools: { + access: ['enrow_find_email', 'enrow_verify_email'], + config: { + tool: (params) => { + switch (params.operation) { + case 'enrow_find_email': + case 'enrow_verify_email': + return params.operation + default: + return 'enrow_find_email' + } + }, + params: (params) => { + const { operation: _operation, ...rest } = params + + // Map unique subBlock IDs back to tool param names + const idToParam: Record = { + ve_email: 'email', + } + + const result: Record = {} + for (const [key, value] of Object.entries(rest)) { + if (value === undefined || value === null || value === '') continue + const mappedKey = idToParam[key] ?? key + result[mappedKey] = value + } + return result + }, + }, + }, + inputs: { + operation: { type: 'string', description: 'Operation to perform' }, + apiKey: { type: 'string', description: 'Enrow API key' }, + fullname: { type: 'string', description: 'Full name for email search' }, + company_domain: { type: 'string', description: 'Company domain for email search' }, + company_name: { type: 'string', description: 'Company name for email search' }, + ve_email: { type: 'string', description: 'Email address to verify' }, + }, + outputs: { + id: { type: 'string', description: 'Enrow job identifier' }, + email: { type: 'string', description: 'Email address found or verified' }, + qualification: { type: 'string', description: '"valid" or "invalid"' }, + fullname: { type: 'string', description: 'Full name of the person (find only)' }, + company_name: { type: 'string', description: 'Company name (find only)' }, + company_domain: { type: 'string', description: 'Company domain (find only)' }, + linkedin_url: { type: 'string', description: 'LinkedIn URL of the person (find only)' }, + }, +} + +export const EnrowBlockMeta = { + tags: ['enrichment', 'sales-engagement'], + url: 'https://enrow.io', +} as const satisfies BlockMeta diff --git a/apps/sim/blocks/blocks/icypeas.ts b/apps/sim/blocks/blocks/icypeas.ts new file mode 100644 index 00000000000..45fdb69150b --- /dev/null +++ b/apps/sim/blocks/blocks/icypeas.ts @@ -0,0 +1,163 @@ +import { IcypeasIcon } from '@/components/icons' +import { AuthMode, type BlockConfig, type BlockMeta, IntegrationType } from '@/blocks/types' +import type { IcypeasResponse } from '@/tools/icypeas/types' + +export const IcypeasBlock: BlockConfig = { + type: 'icypeas', + name: 'Icypeas', + description: 'Find and verify professional email addresses', + longDescription: + 'Integrate Icypeas to find a professional email address from a name and company domain, or verify whether an existing email is valid and deliverable. Results are returned asynchronously via polling.', + docsLink: 'https://docs.sim.ai/tools/icypeas', + category: 'tools', + integrationType: IntegrationType.Sales, + bgColor: '#0EA5E9', + icon: IcypeasIcon, + authMode: AuthMode.ApiKey, + + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Find Email', id: 'icypeas_find_email' }, + { label: 'Verify Email', id: 'icypeas_verify_email' }, + ], + value: () => 'icypeas_find_email', + }, + + // ----------------------------------------------------------------------- + // Find Email + // ----------------------------------------------------------------------- + { + id: 'fe_firstname', + title: 'First Name', + type: 'short-input', + placeholder: 'John', + condition: { field: 'operation', value: 'icypeas_find_email' }, + }, + { + id: 'fe_lastname', + title: 'Last Name', + type: 'short-input', + placeholder: 'Doe', + condition: { field: 'operation', value: 'icypeas_find_email' }, + }, + { + id: 'fe_domainOrCompany', + title: 'Company Domain or Name', + type: 'short-input', + required: true, + placeholder: 'stripe.com', + condition: { field: 'operation', value: 'icypeas_find_email' }, + }, + + // ----------------------------------------------------------------------- + // Verify Email + // ----------------------------------------------------------------------- + { + id: 've_email', + title: 'Email Address', + type: 'short-input', + required: true, + placeholder: 'john@stripe.com', + condition: { field: 'operation', value: 'icypeas_verify_email' }, + }, + + // ----------------------------------------------------------------------- + // API Key — hidden on hosted Sim for all operations (hosted-key supported) + // ----------------------------------------------------------------------- + { + id: 'apiKey', + title: 'API Key', + type: 'short-input', + required: true, + placeholder: 'Enter your Icypeas API key', + password: true, + hideWhenHosted: true, + }, + ], + + tools: { + access: ['icypeas_find_email', 'icypeas_verify_email'], + config: { + tool: (params) => { + switch (params.operation) { + case 'icypeas_find_email': + case 'icypeas_verify_email': + return params.operation + default: + return 'icypeas_find_email' + } + }, + params: (params) => { + const { operation: _operation, ...rest } = params + + // Map unique subBlock IDs back to tool param names. + const idToParam: Record = { + fe_firstname: 'firstname', + fe_lastname: 'lastname', + fe_domainOrCompany: 'domainOrCompany', + ve_email: 'email', + } + + const result: Record = {} + for (const [key, value] of Object.entries(rest)) { + if (value === undefined || value === null || value === '') continue + result[idToParam[key] ?? key] = value + } + return result + }, + }, + }, + + inputs: { + operation: { type: 'string', description: 'Operation to perform' }, + apiKey: { type: 'string', description: 'Icypeas API key' }, + fe_firstname: { type: 'string', description: "Person's first name (find email)" }, + fe_lastname: { type: 'string', description: "Person's last name (find email)" }, + fe_domainOrCompany: { + type: 'string', + description: 'Company domain or name (find email)', + }, + ve_email: { type: 'string', description: 'Email address to verify' }, + }, + + outputs: { + searchId: { + type: 'string', + description: 'Icypeas internal search ID', + }, + status: { + type: 'string', + description: + 'Terminal search status (FOUND, DEBITED, NOT_FOUND, DEBITED_NOT_FOUND, BAD_INPUT, INSUFFICIENT_FUNDS, ABORTED)', + }, + email: { + type: 'string', + description: 'Email address found or verified', + }, + firstname: { + type: 'string', + description: "Found person's first name (find-email only)", + }, + lastname: { + type: 'string', + description: "Found person's last name (find-email only)", + }, + valid: { + type: 'boolean', + description: 'Whether the email is valid/deliverable (verify-email only)', + }, + item: { + type: 'json', + description: 'Full raw item object from the Icypeas results endpoint', + }, + }, +} + +export const IcypeasBlockMeta = { + tags: ['enrichment', 'sales-engagement'], + url: 'https://www.icypeas.com', +} as const satisfies BlockMeta diff --git a/apps/sim/blocks/blocks/leadmagic.ts b/apps/sim/blocks/blocks/leadmagic.ts new file mode 100644 index 00000000000..eaac36043d7 --- /dev/null +++ b/apps/sim/blocks/blocks/leadmagic.ts @@ -0,0 +1,390 @@ +import { LeadMagicIcon } from '@/components/icons' +import { AuthMode, type BlockConfig, type BlockMeta, IntegrationType } from '@/blocks/types' +import type { LeadMagicResponse } from '@/tools/leadmagic/types' + +export const LeadMagicBlock: BlockConfig = { + type: 'leadmagic', + name: 'LeadMagic', + description: 'Find and enrich B2B contacts, emails, mobile numbers, and company data', + authMode: AuthMode.ApiKey, + longDescription: + 'Integrate LeadMagic to find verified work emails by name or company, validate email deliverability, find direct mobile numbers, enrich LinkedIn profiles, reverse-lookup profiles from emails, search companies by domain, identify role holders at accounts, and check account credit balance.', + docsLink: 'https://docs.sim.ai/tools/leadmagic', + category: 'tools', + integrationType: IntegrationType.Sales, + bgColor: '#FFFFFF', + icon: LeadMagicIcon, + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Find Email', id: 'leadmagic_find_email' }, + { label: 'Validate Email', id: 'leadmagic_validate_email' }, + { label: 'Find Mobile', id: 'leadmagic_find_mobile' }, + { label: 'Profile Search', id: 'leadmagic_profile_search' }, + { label: 'Profile to Email', id: 'leadmagic_profile_to_email' }, + { label: 'Email to Profile', id: 'leadmagic_email_to_profile' }, + { label: 'Company Search', id: 'leadmagic_company_search' }, + { label: 'Role Finder', id: 'leadmagic_role_finder' }, + { label: 'Get Credits', id: 'leadmagic_get_credits' }, + ], + value: () => 'leadmagic_find_email', + }, + + // --- Find Email --- + { + id: 'fe_full_name', + title: 'Full Name', + type: 'short-input', + placeholder: 'John Doe', + condition: { field: 'operation', value: 'leadmagic_find_email' }, + }, + { + id: 'fe_domain', + title: 'Company Domain', + type: 'short-input', + placeholder: 'stripe.com', + condition: { field: 'operation', value: 'leadmagic_find_email' }, + }, + { + id: 'fe_company_name', + title: 'Company Name', + type: 'short-input', + placeholder: 'Stripe (if domain unavailable)', + condition: { field: 'operation', value: 'leadmagic_find_email' }, + mode: 'advanced', + }, + + // --- Validate Email --- + { + id: 've_email', + title: 'Email Address', + type: 'short-input', + required: true, + placeholder: 'john@example.com', + condition: { field: 'operation', value: 'leadmagic_validate_email' }, + }, + + // --- Find Mobile --- + { + id: 'fm_profile_url', + title: 'LinkedIn Profile URL', + type: 'short-input', + placeholder: 'https://linkedin.com/in/johndoe', + condition: { field: 'operation', value: 'leadmagic_find_mobile' }, + }, + { + id: 'fm_work_email', + title: 'Work Email', + type: 'short-input', + placeholder: 'john@company.com (alternative to profile URL)', + condition: { field: 'operation', value: 'leadmagic_find_mobile' }, + mode: 'advanced', + }, + + // --- Profile Search --- + { + id: 'ps_profile_url', + title: 'LinkedIn Profile URL', + type: 'short-input', + required: true, + placeholder: 'https://linkedin.com/in/johndoe', + condition: { field: 'operation', value: 'leadmagic_profile_search' }, + }, + { + id: 'extended_response', + title: 'Include Profile Image', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + value: () => 'false', + condition: { field: 'operation', value: 'leadmagic_profile_search' }, + mode: 'advanced', + }, + + // --- Profile to Email --- + { + id: 'pte_profile_url', + title: 'LinkedIn Profile URL', + type: 'short-input', + required: true, + placeholder: 'https://linkedin.com/in/johndoe', + condition: { field: 'operation', value: 'leadmagic_profile_to_email' }, + }, + + // --- Email to Profile --- + { + id: 'etp_work_email', + title: 'Work Email', + type: 'short-input', + placeholder: 'john@company.com', + condition: { field: 'operation', value: 'leadmagic_email_to_profile' }, + }, + { + id: 'etp_personal_email', + title: 'Personal Email', + type: 'short-input', + placeholder: 'john@gmail.com (alternative to work email)', + condition: { field: 'operation', value: 'leadmagic_email_to_profile' }, + mode: 'advanced', + }, + + // --- Company Search --- + { + id: 'cs_company_domain', + title: 'Company Domain', + type: 'short-input', + placeholder: 'stripe.com', + condition: { field: 'operation', value: 'leadmagic_company_search' }, + }, + { + id: 'cs_profile_url', + title: 'LinkedIn Company URL', + type: 'short-input', + placeholder: 'https://linkedin.com/company/stripe', + condition: { field: 'operation', value: 'leadmagic_company_search' }, + mode: 'advanced', + }, + { + id: 'cs_company_name', + title: 'Company Name', + type: 'short-input', + placeholder: 'Stripe', + condition: { field: 'operation', value: 'leadmagic_company_search' }, + mode: 'advanced', + }, + + // --- Role Finder --- + { + id: 'rf_job_title', + title: 'Job Title', + type: 'short-input', + required: true, + placeholder: 'Head of Sales', + condition: { field: 'operation', value: 'leadmagic_role_finder' }, + wandConfig: { + enabled: true, + prompt: + 'Generate a specific job title to search for at the company. Return ONLY the job title — no explanations or extra text.', + placeholder: 'e.g. Head of Sales, CTO, VP Engineering', + }, + }, + { + id: 'rf_company_domain', + title: 'Company Domain', + type: 'short-input', + placeholder: 'stripe.com', + condition: { field: 'operation', value: 'leadmagic_role_finder' }, + }, + { + id: 'rf_company_name', + title: 'Company Name', + type: 'short-input', + placeholder: 'Stripe (if domain unavailable)', + condition: { field: 'operation', value: 'leadmagic_role_finder' }, + mode: 'advanced', + }, + + // API Key — hidden on hosted Sim for operations with hosted-key support + { + id: 'apiKey', + title: 'API Key', + type: 'short-input', + required: true, + placeholder: 'Enter your LeadMagic API key', + password: true, + hideWhenHosted: true, + condition: { field: 'operation', value: 'leadmagic_get_credits', not: true }, + }, + // API Key — always required for the credit-balance lookup (no hosted key) + { + id: 'apiKey', + title: 'API Key', + type: 'short-input', + required: true, + placeholder: 'Enter your LeadMagic API key', + password: true, + condition: { field: 'operation', value: 'leadmagic_get_credits' }, + }, + ], + + tools: { + access: [ + 'leadmagic_validate_email', + 'leadmagic_find_email', + 'leadmagic_find_mobile', + 'leadmagic_profile_search', + 'leadmagic_profile_to_email', + 'leadmagic_email_to_profile', + 'leadmagic_company_search', + 'leadmagic_role_finder', + 'leadmagic_get_credits', + ], + config: { + tool: (params) => { + switch (params.operation) { + case 'leadmagic_validate_email': + case 'leadmagic_find_email': + case 'leadmagic_find_mobile': + case 'leadmagic_profile_search': + case 'leadmagic_profile_to_email': + case 'leadmagic_email_to_profile': + case 'leadmagic_company_search': + case 'leadmagic_role_finder': + case 'leadmagic_get_credits': + return params.operation + default: + return 'leadmagic_find_email' + } + }, + params: (params) => { + const { operation: _operation, ...rest } = params + + const idToParam: Record = { + // Find Email + fe_full_name: 'full_name', + fe_domain: 'domain', + fe_company_name: 'company_name', + // Validate Email + ve_email: 'email', + // Find Mobile + fm_profile_url: 'profile_url', + fm_work_email: 'work_email', + // Profile Search + ps_profile_url: 'profile_url', + // Profile to Email + pte_profile_url: 'profile_url', + // Email to Profile + etp_work_email: 'work_email', + etp_personal_email: 'personal_email', + // Company Search + cs_company_domain: 'company_domain', + cs_profile_url: 'profile_url', + cs_company_name: 'company_name', + // Role Finder + rf_job_title: 'job_title', + rf_company_domain: 'company_domain', + rf_company_name: 'company_name', + } + + const result: Record = {} + for (const [key, value] of Object.entries(rest)) { + if (value === undefined || value === null || value === '') continue + const mappedKey = idToParam[key] ?? key + if (mappedKey === 'extended_response') { + result[mappedKey] = value === true || value === 'true' + } else { + result[mappedKey] = value + } + } + return result + }, + }, + }, + + inputs: { + operation: { type: 'string', description: 'Operation to perform' }, + apiKey: { type: 'string', description: 'LeadMagic API key' }, + // Find Email + fe_full_name: { type: 'string', description: 'Full name (find email)' }, + fe_domain: { type: 'string', description: 'Company domain (find email)' }, + fe_company_name: { type: 'string', description: 'Company name (find email)' }, + // Validate Email + ve_email: { type: 'string', description: 'Email address to validate' }, + // Find Mobile + fm_profile_url: { type: 'string', description: 'LinkedIn profile URL (find mobile)' }, + fm_work_email: { type: 'string', description: 'Work email (find mobile)' }, + // Profile Search + ps_profile_url: { type: 'string', description: 'LinkedIn profile URL (profile search)' }, + extended_response: { type: 'boolean', description: 'Include profile image URL' }, + // Profile to Email + pte_profile_url: { type: 'string', description: 'LinkedIn profile URL (profile to email)' }, + // Email to Profile + etp_work_email: { type: 'string', description: 'Work email (email to profile)' }, + etp_personal_email: { type: 'string', description: 'Personal email (email to profile)' }, + // Company Search + cs_company_domain: { type: 'string', description: 'Company domain (company search)' }, + cs_profile_url: { type: 'string', description: 'LinkedIn company URL (company search)' }, + cs_company_name: { type: 'string', description: 'Company name (company search)' }, + // Role Finder + rf_job_title: { type: 'string', description: 'Job title to find (role finder)' }, + rf_company_domain: { type: 'string', description: 'Company domain (role finder)' }, + rf_company_name: { type: 'string', description: 'Company name (role finder)' }, + }, + + outputs: { + // Shared + credits_consumed: { type: 'number', description: 'Credits charged for this request' }, + message: { type: 'string', description: 'Human-readable status message' }, + // Validate Email + email_status: { + type: 'string', + description: 'Validation result: valid, invalid, or unknown', + }, + is_domain_catch_all: { type: 'boolean', description: 'Whether the domain is a catch-all' }, + mx_record: { type: 'string', description: 'MX record for the domain' }, + mx_provider: { type: 'string', description: 'Email provider (Google, Microsoft, etc.)' }, + mx_gateway: { type: 'string', description: 'MX gateway for the domain' }, + mx_security_gateway: { + type: 'boolean', + description: 'Whether the domain uses a security gateway', + }, + // Find Email / Profile To Email / Validate + email: { type: 'string', description: 'Email address' }, + employment_verified: { type: 'boolean', description: 'Whether employment was verified' }, + has_mx: { type: 'boolean', description: 'Whether the domain has a valid MX record' }, + company_profile_url: { type: 'string', description: 'Company B2B profile URL' }, + // Find Mobile + mobile_number: { type: 'string', description: 'Direct mobile phone number' }, + // Profile Search + first_name: { type: 'string', description: 'First name' }, + last_name: { type: 'string', description: 'Last name' }, + full_name: { type: 'string', description: 'Full name' }, + professional_title: { type: 'string', description: 'Current job title' }, + bio: { type: 'string', description: 'Profile bio / summary' }, + location: { type: 'string', description: 'Location' }, + country: { type: 'string', description: 'Country' }, + followers_range: { type: 'string', description: 'LinkedIn follower range' }, + company_name: { type: 'string', description: 'Current employer name' }, + company_industry: { type: 'string', description: 'Company industry' }, + company_website: { type: 'string', description: 'Company website' }, + total_tenure_years: { type: 'string', description: 'Total career tenure in years' }, + total_tenure_months: { type: 'string', description: 'Total career tenure in months' }, + work_experience: { type: 'array', description: 'Work history entries' }, + education: { type: 'array', description: 'Education history entries' }, + certifications: { type: 'array', description: 'Professional certifications' }, + // Email to Profile + profile_url: { type: 'string', description: 'LinkedIn profile URL' }, + // Company Search + companyName: { type: 'string', description: 'Company name' }, + companyId: { type: 'number', description: 'Internal company ID' }, + industry: { type: 'string', description: 'Industry classification' }, + employeeCount: { type: 'number', description: 'Number of employees' }, + employeeRange: { type: 'string', description: 'Headcount range' }, + founded: { type: 'number', description: 'Year founded' }, + headquarters: { type: 'json', description: 'Headquarters location' }, + revenue: { type: 'string', description: 'Revenue range' }, + funding: { type: 'string', description: 'Total funding' }, + description: { type: 'string', description: 'Company description' }, + specialties: { type: 'array', description: 'Company specialties' }, + competitors: { type: 'array', description: 'Competitor companies' }, + followerCount: { type: 'number', description: 'LinkedIn follower count' }, + twitter_url: { type: 'string', description: 'Twitter/X profile URL' }, + facebook_url: { type: 'string', description: 'Facebook page URL' }, + b2b_profile_url: { type: 'string', description: 'LinkedIn company profile URL' }, + logo_url: { type: 'string', description: 'Company logo URL' }, + // Role Finder + job_title: { type: 'string', description: 'Verified job title at the company' }, + // Get Credits + credits: { type: 'number', description: 'Remaining credit balance' }, + }, +} + +export const LeadMagicBlockMeta = { + tags: ['enrichment', 'sales-engagement'], + url: 'https://leadmagic.io', +} as const satisfies BlockMeta diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index 1d2066d2656..96d2f7f2f6e 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -46,12 +46,14 @@ import { CursorBlock, CursorBlockMeta, CursorV2Block } from '@/blocks/blocks/cur import { DagsterBlock, DagsterBlockMeta } from '@/blocks/blocks/dagster' import { DatabricksBlock, DatabricksBlockMeta } from '@/blocks/blocks/databricks' import { DatadogBlock, DatadogBlockMeta } from '@/blocks/blocks/datadog' +import { DatagmaBlock, DatagmaBlockMeta } from '@/blocks/blocks/datagma' import { DaytonaBlock, DaytonaBlockMeta } from '@/blocks/blocks/daytona' import { DeploymentsBlock } from '@/blocks/blocks/deployments' import { DevinBlock, DevinBlockMeta } from '@/blocks/blocks/devin' import { DiscordBlock, DiscordBlockMeta } from '@/blocks/blocks/discord' import { DocuSignBlock, DocuSignBlockMeta } from '@/blocks/blocks/docusign' import { DropboxBlock, DropboxBlockMeta } from '@/blocks/blocks/dropbox' +import { DropcontactBlock, DropcontactBlockMeta } from '@/blocks/blocks/dropcontact' import { DSPyBlock, DSPyBlockMeta } from '@/blocks/blocks/dspy' import { DubBlock, DubBlockMeta } from '@/blocks/blocks/dub' import { DuckDuckGoBlock, DuckDuckGoBlockMeta } from '@/blocks/blocks/duckduckgo' @@ -61,6 +63,7 @@ import { ElevenLabsBlock, ElevenLabsBlockMeta } from '@/blocks/blocks/elevenlabs import { EmailBisonBlock, EmailBisonBlockMeta } from '@/blocks/blocks/emailbison' import { EnrichBlock, EnrichBlockMeta } from '@/blocks/blocks/enrich' import { EnrichmentBlock, EnrichmentBlockMeta } from '@/blocks/blocks/enrichment' +import { EnrowBlock, EnrowBlockMeta } from '@/blocks/blocks/enrow' import { EvaluatorBlock } from '@/blocks/blocks/evaluator' import { EvernoteBlock, EvernoteBlockMeta } from '@/blocks/blocks/evernote' import { ExaBlock, ExaBlockMeta } from '@/blocks/blocks/exa' @@ -132,6 +135,7 @@ import { HuggingFaceBlock, HuggingFaceBlockMeta } from '@/blocks/blocks/huggingf import { HumanInTheLoopBlock } from '@/blocks/blocks/human_in_the_loop' import { HunterBlock, HunterBlockMeta } from '@/blocks/blocks/hunter' import { IAMBlock, IAMBlockMeta } from '@/blocks/blocks/iam' +import { IcypeasBlock, IcypeasBlockMeta } from '@/blocks/blocks/icypeas' import { IdentityCenterBlock, IdentityCenterBlockMeta } from '@/blocks/blocks/identity_center' import { ImageGeneratorBlock, ImageGeneratorV2Block } from '@/blocks/blocks/image_generator' import { ImapBlock, ImapBlockMeta } from '@/blocks/blocks/imap' @@ -162,6 +166,7 @@ import { KnowledgeBlock } from '@/blocks/blocks/knowledge' import { LangsmithBlock, LangsmithBlockMeta } from '@/blocks/blocks/langsmith' import { LatexBlock, LatexBlockMeta } from '@/blocks/blocks/latex' import { LaunchDarklyBlock, LaunchDarklyBlockMeta } from '@/blocks/blocks/launchdarkly' +import { LeadMagicBlock, LeadMagicBlockMeta } from '@/blocks/blocks/leadmagic' import { LemlistBlock, LemlistBlockMeta } from '@/blocks/blocks/lemlist' import { LinearBlock, LinearBlockMeta, LinearV2Block } from '@/blocks/blocks/linear' import { LinkedInBlock, LinkedInBlockMeta } from '@/blocks/blocks/linkedin' @@ -379,12 +384,14 @@ const BLOCK_REGISTRY: Record = { dagster: DagsterBlock, databricks: DatabricksBlock, datadog: DatadogBlock, + datagma: DatagmaBlock, daytona: DaytonaBlock, deployments: DeploymentsBlock, devin: DevinBlock, discord: DiscordBlock, docusign: DocuSignBlock, dropbox: DropboxBlock, + dropcontact: DropcontactBlock, dspy: DSPyBlock, dub: DubBlock, duckduckgo: DuckDuckGoBlock, @@ -394,6 +401,7 @@ const BLOCK_REGISTRY: Record = { emailbison: EmailBisonBlock, enrich: EnrichBlock, enrichment: EnrichmentBlock, + enrow: EnrowBlock, evaluator: EvaluatorBlock, evernote: EvernoteBlock, exa: ExaBlock, @@ -454,6 +462,7 @@ const BLOCK_REGISTRY: Record = { human_in_the_loop: HumanInTheLoopBlock, hunter: HunterBlock, iam: IAMBlock, + icypeas: IcypeasBlock, identity_center: IdentityCenterBlock, image_generator: ImageGeneratorBlock, image_generator_v2: ImageGeneratorV2Block, @@ -474,6 +483,7 @@ const BLOCK_REGISTRY: Record = { langsmith: LangsmithBlock, latex: LatexBlock, launchdarkly: LaunchDarklyBlock, + leadmagic: LeadMagicBlock, lemlist: LemlistBlock, linear: LinearBlock, linear_v2: LinearV2Block, @@ -678,11 +688,13 @@ const BLOCK_META_REGISTRY: Record = { dagster: DagsterBlockMeta, databricks: DatabricksBlockMeta, datadog: DatadogBlockMeta, + datagma: DatagmaBlockMeta, daytona: DaytonaBlockMeta, devin: DevinBlockMeta, discord: DiscordBlockMeta, docusign: DocuSignBlockMeta, dropbox: DropboxBlockMeta, + dropcontact: DropcontactBlockMeta, dspy: DSPyBlockMeta, dub: DubBlockMeta, duckduckgo: DuckDuckGoBlockMeta, @@ -692,6 +704,7 @@ const BLOCK_META_REGISTRY: Record = { emailbison: EmailBisonBlockMeta, enrich: EnrichBlockMeta, enrichment: EnrichmentBlockMeta, + enrow: EnrowBlockMeta, evernote: EvernoteBlockMeta, exa: ExaBlockMeta, extend: ExtendBlockMeta, @@ -738,6 +751,7 @@ const BLOCK_META_REGISTRY: Record = { huggingface: HuggingFaceBlockMeta, hunter: HunterBlockMeta, iam: IAMBlockMeta, + icypeas: IcypeasBlockMeta, identity_center: IdentityCenterBlockMeta, imap: ImapBlockMeta, incidentio: IncidentioBlockMeta, @@ -754,6 +768,7 @@ const BLOCK_META_REGISTRY: Record = { langsmith: LangsmithBlockMeta, latex: LatexBlockMeta, launchdarkly: LaunchDarklyBlockMeta, + leadmagic: LeadMagicBlockMeta, lemlist: LemlistBlockMeta, linear: LinearBlockMeta, linkedin: LinkedInBlockMeta, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index b53f133f9ba..348607ec493 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -7870,3 +7870,114 @@ export function TriggerDevIcon(props: SVGProps) { ) } + +/** Datagma brand icon: navy square with the white Datagma "D" mark. */ +export function DatagmaIcon(props: SVGProps) { + return ( + + + + + ) +} + +/** LeadMagic brand icon: purple gradient tile with the white spark mark. */ +export function LeadMagicIcon(props: SVGProps) { + const id = useId() + const gradient = `leadmagic_grad_${id}` + return ( + + + + + + + + + + + + + + + + + + ) +} + +/** Dropcontact brand icon: teal disc with the white open-"d" contact mark. */ +export function DropcontactIcon(props: SVGProps) { + return ( + + + + + + ) +} + +/** Icypeas brand icon: dark tile with the teal ring + rising-chart mark. */ +export function IcypeasIcon(props: SVGProps) { + return ( + + + + + + + ) +} + +/** Enrow brand icon: blue tile with the three white stacked rows. */ +export function EnrowIcon(props: SVGProps) { + return ( + + + + + ) +} diff --git a/apps/sim/enrichments/company-domain/company-domain.test.ts b/apps/sim/enrichments/company-domain/company-domain.test.ts new file mode 100644 index 00000000000..3c4fd0fe6f0 --- /dev/null +++ b/apps/sim/enrichments/company-domain/company-domain.test.ts @@ -0,0 +1,44 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { companyDomainEnrichment } from '@/enrichments/company-domain/company-domain' +import type { EnrichmentProvider } from '@/enrichments/types' + +function provider(id: string): EnrichmentProvider { + const p = companyDomainEnrichment.providers.find((x) => x.id === id) + if (!p) throw new Error(`Provider ${id} not found in company-domain cascade`) + return p +} + +const nameInput = { companyName: 'Acme Inc' } + +describe('company-domain enrichment cascade', () => { + it('chains PDL then Datagma', () => { + expect(companyDomainEnrichment.providers.map((p) => p.id)).toEqual(['pdl', 'datagma']) + }) + + describe('pdl', () => { + const p = provider('pdl') + it('matches by name and normalizes the returned website', () => { + expect(p.toolId).toBe('pdl_company_enrich') + expect(p.buildParams(nameInput)).toEqual({ name: 'Acme Inc' }) + expect(p.buildParams({ companyName: '' })).toBeNull() + expect(p.mapOutput({ company: { website: 'https://www.acme.com' } })).toEqual({ + domain: 'acme.com', + }) + expect(p.mapOutput({ company: {} })).toBeNull() + }) + }) + + describe('datagma', () => { + const p = provider('datagma') + it('enriches by company name and normalizes the returned website', () => { + expect(p.toolId).toBe('datagma_enrich_company') + expect(p.buildParams(nameInput)).toEqual({ data: 'Acme Inc' }) + expect(p.buildParams({ companyName: '' })).toBeNull() + expect(p.mapOutput({ website: 'https://www.acme.com/' })).toEqual({ domain: 'acme.com' }) + expect(p.mapOutput({})).toBeNull() + }) + }) +}) diff --git a/apps/sim/enrichments/company-domain/company-domain.ts b/apps/sim/enrichments/company-domain/company-domain.ts index 7d6fc95860d..7739d46df71 100644 --- a/apps/sim/enrichments/company-domain/company-domain.ts +++ b/apps/sim/enrichments/company-domain/company-domain.ts @@ -4,7 +4,7 @@ import type { EnrichmentConfig } from '@/enrichments/types' /** * Company Domain enrichment. Resolves a company's website domain from its name - * via a People Data Labs company match. + * via a People Data Labs company match, falling back to Datagma's company enrich. */ export const companyDomainEnrichment: EnrichmentConfig = { id: 'company-domain', @@ -29,5 +29,20 @@ export const companyDomainEnrichment: EnrichmentConfig = { return domain ? { domain } : null }, }), + toolProvider({ + id: 'datagma', + label: 'Datagma', + toolId: 'datagma_enrich_company', + buildParams: (inputs) => { + // Datagma's `data` accepts a company name and returns its website. + const data = str(inputs.companyName) + if (!data) return null + return { data } + }, + mapOutput: (output) => { + const domain = normalizeDomain(output.website) + return domain ? { domain } : null + }, + }), ], } diff --git a/apps/sim/enrichments/company-info/company-info.test.ts b/apps/sim/enrichments/company-info/company-info.test.ts new file mode 100644 index 00000000000..d799b2ddb2d --- /dev/null +++ b/apps/sim/enrichments/company-info/company-info.test.ts @@ -0,0 +1,67 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { companyInfoEnrichment } from '@/enrichments/company-info/company-info' +import type { EnrichmentProvider } from '@/enrichments/types' + +function provider(id: string): EnrichmentProvider { + const p = companyInfoEnrichment.providers.find((x) => x.id === id) + if (!p) throw new Error(`Provider ${id} not found in company-info cascade`) + return p +} + +const domainInput = { domain: 'https://www.acme.com/about' } + +describe('company-info enrichment cascade', () => { + it('chains the company-info providers in waterfall order', () => { + expect(companyInfoEnrichment.providers.map((p) => p.id)).toEqual([ + 'hunter', + 'pdl', + 'datagma', + 'leadmagic', + ]) + }) + + describe('hunter', () => { + const p = provider('hunter') + it('normalizes the domain and maps size/description', () => { + expect(p.toolId).toBe('hunter_companies_find') + expect(p.buildParams(domainInput)).toEqual({ domain: 'acme.com' }) + expect(p.buildParams({ domain: '' })).toBeNull() + expect(p.mapOutput({ size: '11-50', description: 'Payments' })).toEqual({ + employeeCount: '11-50', + description: 'Payments', + }) + expect(p.mapOutput({})).toEqual({}) + }) + }) + + describe('datagma', () => { + const p = provider('datagma') + it('passes the normalized domain as data and maps companySize/shortDescription', () => { + expect(p.toolId).toBe('datagma_enrich_company') + expect(p.buildParams(domainInput)).toEqual({ data: 'acme.com' }) + expect(p.buildParams({ domain: '' })).toBeNull() + expect(p.mapOutput({ companySize: '11-50', shortDescription: 'Payments' })).toEqual({ + employeeCount: '11-50', + description: 'Payments', + }) + expect(p.mapOutput({})).toEqual({}) + }) + }) + + describe('leadmagic', () => { + const p = provider('leadmagic') + it('searches by domain and prefers the headcount range', () => { + expect(p.toolId).toBe('leadmagic_company_search') + expect(p.buildParams(domainInput)).toEqual({ company_domain: 'acme.com' }) + expect(p.buildParams({ domain: '' })).toBeNull() + expect( + p.mapOutput({ employeeRange: '11-50', employeeCount: 42, description: 'Pay' }) + ).toEqual({ employeeCount: '11-50', description: 'Pay' }) + expect(p.mapOutput({ employeeCount: 42 })).toEqual({ employeeCount: '42' }) + expect(p.mapOutput({})).toEqual({}) + }) + }) +}) diff --git a/apps/sim/enrichments/company-info/company-info.ts b/apps/sim/enrichments/company-info/company-info.ts index b3fdc541ecd..b67a8ed8211 100644 --- a/apps/sim/enrichments/company-info/company-info.ts +++ b/apps/sim/enrichments/company-info/company-info.ts @@ -5,11 +5,11 @@ import type { EnrichmentConfig } from '@/enrichments/types' /** * Company Info enrichment. Looks up a company by domain, trying Hunter first - * (free) then People Data Labs as a fallback. Outputs are limited to the fields - * both providers reliably return — employee count and description — so the - * result stays consistent regardless of which provider fills the cell. - * `employeeCount` is a string so Hunter's range bucket (e.g. `"11-50"`) and - * PDL's exact count map onto the same column. + * (free) then People Data Labs, then Datagma and LeadMagic as fallbacks. Outputs + * are limited to the fields the providers reliably return — employee count and + * description — so the result stays consistent regardless of which provider fills + * the cell. `employeeCount` is a string so Hunter's range bucket (e.g. `"11-50"`), + * PDL's exact count, and LeadMagic's range all map onto the same column. */ export const companyInfoEnrichment: EnrichmentConfig = { id: 'company-info', @@ -55,5 +55,38 @@ export const companyInfoEnrichment: EnrichmentConfig = { }) }, }), + toolProvider({ + id: 'datagma', + label: 'Datagma', + toolId: 'datagma_enrich_company', + buildParams: (inputs) => { + const data = normalizeDomain(inputs.domain) + if (!data) return null + return { data } + }, + mapOutput: (output) => { + return filterUndefined({ + employeeCount: str(output.companySize) || undefined, + description: str(output.shortDescription) || undefined, + }) + }, + }), + toolProvider({ + id: 'leadmagic', + label: 'LeadMagic', + toolId: 'leadmagic_company_search', + buildParams: (inputs) => { + const companyDomain = normalizeDomain(inputs.domain) + if (!companyDomain) return null + return { company_domain: companyDomain } + }, + mapOutput: (output) => { + // Prefer the headcount range to match Hunter's bucket style; fall back to the exact count. + return filterUndefined({ + employeeCount: str(output.employeeRange) || str(output.employeeCount) || undefined, + description: str(output.description) || undefined, + }) + }, + }), ], } diff --git a/apps/sim/enrichments/email-verification/email-verification.test.ts b/apps/sim/enrichments/email-verification/email-verification.test.ts new file mode 100644 index 00000000000..694c8dd0349 --- /dev/null +++ b/apps/sim/enrichments/email-verification/email-verification.test.ts @@ -0,0 +1,85 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { emailVerificationEnrichment } from '@/enrichments/email-verification/email-verification' +import type { EnrichmentProvider } from '@/enrichments/types' + +function provider(id: string): EnrichmentProvider { + const p = emailVerificationEnrichment.providers.find((x) => x.id === id) + if (!p) throw new Error(`Provider ${id} not found in email-verification cascade`) + return p +} + +const emailInput = { email: ' john@acme.com ' } + +describe('email-verification enrichment cascade', () => { + it('chains the hosted verifiers in waterfall order', () => { + expect(emailVerificationEnrichment.providers.map((p) => p.id)).toEqual([ + 'zerobounce', + 'neverbounce', + 'millionverifier', + 'icypeas', + 'enrow', + ]) + }) + + describe('zerobounce', () => { + const p = provider('zerobounce') + it('trims the email and falls through on missing/unknown verdict', () => { + expect(p.toolId).toBe('zerobounce_verify_email') + expect(p.buildParams(emailInput)).toEqual({ email: 'john@acme.com' }) + expect(p.buildParams({ email: '' })).toBeNull() + expect(p.mapOutput({ status: 'valid', deliverable: true })).toEqual({ + status: 'valid', + deliverable: true, + }) + expect(p.mapOutput({ status: 'unknown', deliverable: false })).toBeNull() + expect(p.mapOutput({})).toBeNull() + }) + }) + + describe('icypeas', () => { + const p = provider('icypeas') + it('maps FOUND/DEBITED to deliverable and NOT_FOUND to undeliverable', () => { + expect(p.toolId).toBe('icypeas_verify_email') + expect(p.buildParams(emailInput)).toEqual({ email: 'john@acme.com' }) + expect(p.buildParams({ email: '' })).toBeNull() + expect(p.mapOutput({ status: 'FOUND' })).toEqual({ status: 'valid', deliverable: true }) + expect(p.mapOutput({ status: 'DEBITED' })).toEqual({ status: 'valid', deliverable: true }) + expect(p.mapOutput({ status: 'NOT_FOUND' })).toEqual({ + status: 'invalid', + deliverable: false, + }) + expect(p.mapOutput({ status: 'DEBITED_NOT_FOUND' })).toEqual({ + status: 'invalid', + deliverable: false, + }) + }) + it('falls through on inconclusive statuses', () => { + expect(p.mapOutput({ status: 'BAD_INPUT' })).toBeNull() + expect(p.mapOutput({ status: 'INSUFFICIENT_FUNDS' })).toBeNull() + expect(p.mapOutput({ status: 'ABORTED' })).toBeNull() + expect(p.mapOutput({})).toBeNull() + }) + }) + + describe('enrow', () => { + const p = provider('enrow') + it('maps the valid/invalid qualifier and falls through otherwise', () => { + expect(p.toolId).toBe('enrow_verify_email') + expect(p.buildParams(emailInput)).toEqual({ email: 'john@acme.com' }) + expect(p.buildParams({ email: '' })).toBeNull() + expect(p.mapOutput({ qualification: 'valid' })).toEqual({ + status: 'valid', + deliverable: true, + }) + expect(p.mapOutput({ qualification: 'invalid' })).toEqual({ + status: 'invalid', + deliverable: false, + }) + expect(p.mapOutput({ qualification: null })).toBeNull() + expect(p.mapOutput({})).toBeNull() + }) + }) +}) diff --git a/apps/sim/enrichments/email-verification/email-verification.ts b/apps/sim/enrichments/email-verification/email-verification.ts index 71cb3f9b321..d6d7417c511 100644 --- a/apps/sim/enrichments/email-verification/email-verification.ts +++ b/apps/sim/enrichments/email-verification/email-verification.ts @@ -5,10 +5,11 @@ import type { EnrichmentConfig } from '@/enrichments/types' /** * Email Verification enrichment. Checks an email address's deliverability via a * verifier waterfall — ZeroBounce first (highest coverage), then NeverBounce, - * then MillionVerifier. A provider that returns a definitive verdict - * (valid / invalid / catch_all / disposable / etc.) fills the cell; a provider - * that can only return `unknown` falls through to the next so the row gets the - * most confident answer available. All providers support hosted keys. + * then MillionVerifier, then Icypeas, then Enrow. A provider that returns a + * definitive verdict (valid / invalid / catch_all / disposable / etc.) fills the + * cell; a provider that can only return `unknown` falls through to the next so + * the row gets the most confident answer available. All providers support hosted + * keys. */ export const emailVerificationEnrichment: EnrichmentConfig = { id: 'email-verification', @@ -67,5 +68,42 @@ export const emailVerificationEnrichment: EnrichmentConfig = { return { status, deliverable: output.deliverable === true } }, }), + toolProvider({ + id: 'icypeas', + label: 'Icypeas', + toolId: 'icypeas_verify_email', + buildParams: (inputs) => { + const email = str(inputs.email) + if (!email) return null + return { email } + }, + mapOutput: (output) => { + // FOUND/DEBITED → deliverable, NOT_FOUND/DEBITED_NOT_FOUND → undeliverable. + // Bad input / insufficient funds / aborted are inconclusive → fall through. + const status = str(output.status) + if (status === 'FOUND' || status === 'DEBITED') + return { status: 'valid', deliverable: true } + if (status === 'NOT_FOUND' || status === 'DEBITED_NOT_FOUND') + return { status: 'invalid', deliverable: false } + return null + }, + }), + toolProvider({ + id: 'enrow', + label: 'Enrow', + toolId: 'enrow_verify_email', + buildParams: (inputs) => { + const email = str(inputs.email) + if (!email) return null + return { email } + }, + mapOutput: (output) => { + // Enrow returns a "valid" / "invalid" qualifier; anything else is inconclusive. + const qualification = str(output.qualification).toLowerCase() + if (qualification === 'valid') return { status: 'valid', deliverable: true } + if (qualification === 'invalid') return { status: 'invalid', deliverable: false } + return null + }, + }), ], } diff --git a/apps/sim/enrichments/phone-number/phone-number.test.ts b/apps/sim/enrichments/phone-number/phone-number.test.ts index 2aa82c33eed..66931661564 100644 --- a/apps/sim/enrichments/phone-number/phone-number.test.ts +++ b/apps/sim/enrichments/phone-number/phone-number.test.ts @@ -21,6 +21,9 @@ describe('phone-number enrichment cascade', () => { 'wiza', 'findymail', 'prospeo', + 'leadmagic', + 'datagma', + 'dropcontact', ]) }) @@ -75,4 +78,44 @@ describe('phone-number enrichment cascade', () => { expect(p.mapOutput({ person: { mobile: { mobile: '+1555' } } })).toEqual({ phone: '+1555' }) }) }) + + describe('leadmagic', () => { + const p = provider('leadmagic') + it('keys off the LinkedIn URL and skips without one', () => { + expect(p.toolId).toBe('leadmagic_find_mobile') + expect(p.buildParams(linkedinOnly)).toEqual({ + profile_url: 'https://linkedin.com/in/johndoe', + }) + expect(p.buildParams(nameDomain)).toBeNull() + expect(p.mapOutput({ mobile_number: '+1555' })).toEqual({ phone: '+1555' }) + expect(p.mapOutput({ mobile_number: null })).toBeNull() + }) + }) + + describe('datagma', () => { + const p = provider('datagma') + it('passes the LinkedIn URL as username and skips without one', () => { + expect(p.toolId).toBe('datagma_find_phone') + expect(p.buildParams(linkedinOnly)).toEqual({ username: 'https://linkedin.com/in/johndoe' }) + expect(p.buildParams(nameDomain)).toBeNull() + expect(p.mapOutput({ phone: '+1555' })).toEqual({ phone: '+1555' }) + expect(p.mapOutput({ phone: null })).toBeNull() + }) + }) + + describe('dropcontact', () => { + const p = provider('dropcontact') + it('enriches via name+company and prefers mobile_phone', () => { + expect(p.toolId).toBe('dropcontact_enrich_contact') + expect(p.buildParams(nameDomain)).toEqual({ full_name: 'John Doe', website: 'acme.com' }) + expect(p.buildParams(linkedinOnly)).toEqual({ + full_name: 'John Doe', + linkedin: 'https://linkedin.com/in/johndoe', + }) + expect(p.buildParams({ companyDomain: 'acme.com' })).toBeNull() + expect(p.mapOutput({ mobile_phone: '+1555', phone: '+1999' })).toEqual({ phone: '+1555' }) + expect(p.mapOutput({ phone: '+1999' })).toEqual({ phone: '+1999' }) + expect(p.mapOutput({})).toBeNull() + }) + }) }) diff --git a/apps/sim/enrichments/phone-number/phone-number.ts b/apps/sim/enrichments/phone-number/phone-number.ts index 6a7ad186cdd..1a9fa879dd5 100644 --- a/apps/sim/enrichments/phone-number/phone-number.ts +++ b/apps/sim/enrichments/phone-number/phone-number.ts @@ -7,9 +7,10 @@ import type { EnrichmentConfig } from '@/enrichments/types' * Phone Number enrichment. Finds a contact's phone number from a full name plus * any available identifiers (company domain, LinkedIn URL) via a waterfall: * People Data Labs (name match) → Wiza reveal → Findymail (LinkedIn) → Prospeo - * mobile. Each provider opportunistically uses whatever identifiers the row - * provides and self-skips when it has none usable, so adding more inputs widens - * coverage without reordering. First phone wins; all providers support hosted keys. + * mobile → LeadMagic (LinkedIn) → Datagma (LinkedIn) → Dropcontact (name+company). + * Each provider opportunistically uses whatever identifiers the row provides and + * self-skips when it has none usable, so adding more inputs widens coverage + * without reordering. First phone wins; all providers support hosted keys. */ export const phoneNumberEnrichment: EnrichmentConfig = { id: 'phone-number', @@ -106,5 +107,55 @@ export const phoneNumberEnrichment: EnrichmentConfig = { return phone ? { phone } : null }, }), + toolProvider({ + id: 'leadmagic', + label: 'LeadMagic', + toolId: 'leadmagic_find_mobile', + buildParams: (inputs) => { + // LeadMagic's mobile finder keys off a LinkedIn URL. + const profileUrl = str(inputs.linkedinUrl) + if (!profileUrl) return null + return { profile_url: profileUrl } + }, + mapOutput: (output) => { + const phone = str(output.mobile_number) + return phone ? { phone } : null + }, + }), + toolProvider({ + id: 'datagma', + label: 'Datagma', + toolId: 'datagma_find_phone', + buildParams: (inputs) => { + // Datagma's phone finder takes the full LinkedIn URL as `username`. + const username = str(inputs.linkedinUrl) + if (!username) return null + return { username } + }, + mapOutput: (output) => { + const phone = str(output.phone) + return phone ? { phone } : null + }, + }), + toolProvider({ + id: 'dropcontact', + label: 'Dropcontact', + toolId: 'dropcontact_enrich_contact', + buildParams: (inputs) => { + const fullName = str(inputs.fullName) + const website = normalizeDomain(inputs.companyDomain) + const linkedin = str(inputs.linkedinUrl) + if (!fullName || (!website && !linkedin)) return null + return filterUndefined({ + full_name: fullName, + website: website || undefined, + linkedin: linkedin || undefined, + }) + }, + mapOutput: (output) => { + const phone = str(output.mobile_phone) || str(output.phone) + return phone ? { phone } : null + }, + }), ], } diff --git a/apps/sim/enrichments/work-email/work-email.test.ts b/apps/sim/enrichments/work-email/work-email.test.ts index 41bf50bc231..23f4b2ab56b 100644 --- a/apps/sim/enrichments/work-email/work-email.test.ts +++ b/apps/sim/enrichments/work-email/work-email.test.ts @@ -23,6 +23,11 @@ describe('work-email enrichment cascade', () => { 'prospeo', 'wiza', 'pdl', + 'datagma', + 'leadmagic', + 'dropcontact', + 'icypeas', + 'enrow', ]) }) @@ -83,4 +88,73 @@ describe('work-email enrichment cascade', () => { expect(p.mapOutput({ email: 'j@acme.com' })).toEqual({ email: 'j@acme.com' }) }) }) + + describe('datagma', () => { + const p = provider('datagma') + it('maps name + normalized company domain', () => { + expect(p.toolId).toBe('datagma_find_email') + expect(p.buildParams(nameDomain)).toEqual({ fullName: 'John Doe', company: 'acme.com' }) + expect(p.buildParams({ fullName: 'John Doe' })).toBeNull() + expect(p.mapOutput({ email: 'j@acme.com' })).toEqual({ email: 'j@acme.com' }) + expect(p.mapOutput({})).toBeNull() + }) + }) + + describe('leadmagic', () => { + const p = provider('leadmagic') + it('splits the name and requires a domain', () => { + expect(p.toolId).toBe('leadmagic_find_email') + expect(p.buildParams(nameDomain)).toEqual({ + first_name: 'John', + last_name: 'Doe', + domain: 'acme.com', + }) + expect(p.buildParams({ fullName: 'John Doe' })).toBeNull() + expect(p.buildParams({ fullName: 'Cher', companyDomain: 'acme.com' })).toBeNull() + expect(p.mapOutput({ email: 'j@acme.com' })).toEqual({ email: 'j@acme.com' }) + }) + }) + + describe('dropcontact', () => { + const p = provider('dropcontact') + it('enriches from name plus company or LinkedIn', () => { + expect(p.toolId).toBe('dropcontact_enrich_contact') + expect(p.buildParams(nameDomain)).toEqual({ full_name: 'John Doe', website: 'acme.com' }) + expect(p.buildParams(linkedinOnly)).toEqual({ + full_name: 'John Doe', + linkedin: 'https://linkedin.com/in/johndoe', + }) + expect(p.buildParams({ companyDomain: 'acme.com' })).toBeNull() + expect(p.mapOutput({ email: 'j@acme.com' })).toEqual({ email: 'j@acme.com' }) + expect(p.mapOutput({})).toBeNull() + }) + }) + + describe('icypeas', () => { + const p = provider('icypeas') + it('splits the name and requires a domain/company', () => { + expect(p.toolId).toBe('icypeas_find_email') + expect(p.buildParams(nameDomain)).toEqual({ + firstname: 'John', + lastname: 'Doe', + domainOrCompany: 'acme.com', + }) + expect(p.buildParams({ fullName: 'John Doe' })).toBeNull() + expect(p.mapOutput({ email: 'j@acme.com' })).toEqual({ email: 'j@acme.com' }) + }) + }) + + describe('enrow', () => { + const p = provider('enrow') + it('maps full name + company domain', () => { + expect(p.toolId).toBe('enrow_find_email') + expect(p.buildParams(nameDomain)).toEqual({ + fullname: 'John Doe', + company_domain: 'acme.com', + }) + expect(p.buildParams({ fullName: 'John Doe' })).toBeNull() + expect(p.mapOutput({ email: 'j@acme.com' })).toEqual({ email: 'j@acme.com' }) + expect(p.mapOutput({})).toBeNull() + }) + }) }) diff --git a/apps/sim/enrichments/work-email/work-email.ts b/apps/sim/enrichments/work-email/work-email.ts index 2076e18eb46..64840154faf 100644 --- a/apps/sim/enrichments/work-email/work-email.ts +++ b/apps/sim/enrichments/work-email/work-email.ts @@ -8,7 +8,8 @@ import type { EnrichmentConfig } from '@/enrichments/types' * available identifiers (company domain, LinkedIn URL) via a provider waterfall: * deterministic finders first (Hunter, Findymail by name then by LinkedIn), then * enrichment/reveal providers (Prospeo, Wiza), then People Data Labs as a broad - * record-match fallback. Each provider opportunistically uses whatever + * record-match fallback, then Datagma, LeadMagic, Dropcontact, Icypeas, and Enrow + * as additional finders. Each provider opportunistically uses whatever * identifiers the row provides and self-skips when it has none usable, so adding * more inputs widens coverage. First email wins; all providers support hosted keys. */ @@ -133,5 +134,85 @@ export const workEmailEnrichment: EnrichmentConfig = { return email ? { email } : null }, }), + toolProvider({ + id: 'datagma', + label: 'Datagma', + toolId: 'datagma_find_email', + buildParams: (inputs) => { + const fullName = str(inputs.fullName) + const company = normalizeDomain(inputs.companyDomain) + if (!fullName || !company) return null + return { fullName, company } + }, + mapOutput: (output) => { + const email = str(output.email) + return email ? { email } : null + }, + }), + toolProvider({ + id: 'leadmagic', + label: 'LeadMagic', + toolId: 'leadmagic_find_email', + buildParams: (inputs) => { + const name = splitName(inputs.fullName) + const domain = normalizeDomain(inputs.companyDomain) + if (!name || !domain) return null + return { first_name: name.firstName, last_name: name.lastName, domain } + }, + mapOutput: (output) => { + const email = str(output.email) + return email ? { email } : null + }, + }), + toolProvider({ + id: 'dropcontact', + label: 'Dropcontact', + toolId: 'dropcontact_enrich_contact', + buildParams: (inputs) => { + const fullName = str(inputs.fullName) + const website = normalizeDomain(inputs.companyDomain) + const linkedin = str(inputs.linkedinUrl) + if (!fullName || (!website && !linkedin)) return null + return filterUndefined({ + full_name: fullName, + website: website || undefined, + linkedin: linkedin || undefined, + }) + }, + mapOutput: (output) => { + const email = str(output.email) + return email ? { email } : null + }, + }), + toolProvider({ + id: 'icypeas', + label: 'Icypeas', + toolId: 'icypeas_find_email', + buildParams: (inputs) => { + const name = splitName(inputs.fullName) + const domainOrCompany = normalizeDomain(inputs.companyDomain) + if (!name || !domainOrCompany) return null + return { firstname: name.firstName, lastname: name.lastName, domainOrCompany } + }, + mapOutput: (output) => { + const email = str(output.email) + return email ? { email } : null + }, + }), + toolProvider({ + id: 'enrow', + label: 'Enrow', + toolId: 'enrow_find_email', + buildParams: (inputs) => { + const fullname = str(inputs.fullName) + const company_domain = normalizeDomain(inputs.companyDomain) + if (!fullname || !company_domain) return null + return { fullname, company_domain } + }, + mapOutput: (output) => { + const email = str(output.email) + return email ? { email } : null + }, + }), ], } diff --git a/apps/sim/lib/api/contracts/byok-keys.ts b/apps/sim/lib/api/contracts/byok-keys.ts index 0c682458b88..0c4f10f7085 100644 --- a/apps/sim/lib/api/contracts/byok-keys.ts +++ b/apps/sim/lib/api/contracts/byok-keys.ts @@ -29,6 +29,11 @@ export const byokProviderIdSchema = z.enum([ 'zerobounce', 'neverbounce', 'millionverifier', + 'datagma', + 'dropcontact', + 'leadmagic', + 'icypeas', + 'enrow', ]) /** Maximum number of keys a workspace may store per provider. */ diff --git a/apps/sim/tools/datagma-hosting.test.ts b/apps/sim/tools/datagma-hosting.test.ts new file mode 100644 index 00000000000..e7d4654bb3d --- /dev/null +++ b/apps/sim/tools/datagma-hosting.test.ts @@ -0,0 +1,104 @@ +/** + * @vitest-environment node + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { enrichCompanyTool } from '@/tools/datagma/enrich_company' +import { enrichPersonTool } from '@/tools/datagma/enrich_person' +import { findEmailTool } from '@/tools/datagma/find_email' +import { findPhoneTool } from '@/tools/datagma/find_phone' +import { getCreditsTool } from '@/tools/datagma/get_credits' +import { DATAGMA_CREDIT_USD } from '@/tools/datagma/hosting' +import type { ToolConfig } from '@/tools/types' + +afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +function cost(tool: ToolConfig, params: any, output: Record) { + const pricing = tool.hosting?.pricing + if (!pricing || pricing.type !== 'custom') throw new Error('Expected custom pricing') + const result = pricing.getCost(params, output) + return typeof result === 'number' ? { cost: result } : result +} + +describe('Datagma hosted key config', () => { + it('declares the shared env prefix and BYOK provider on all credit-consuming tools', () => { + for (const tool of [findEmailTool, enrichPersonTool, enrichCompanyTool, findPhoneTool]) { + expect(tool.hosting?.envKeyPrefix).toBe('DATAGMA_API_KEY') + expect(tool.hosting?.byokProviderId).toBe('datagma') + } + }) + + it('get_credits tool has no hosting config (always BYOK)', () => { + expect(getCreditsTool.hosting).toBeUndefined() + }) +}) + +describe('Datagma find email pricing', () => { + it('charges 1 credit when a verified email is found', () => { + expect(cost(findEmailTool, {}, { email: 'john@stripe.com' }).cost).toBeCloseTo( + DATAGMA_CREDIT_USD + ) + }) + + it('charges 0 credits when no email is returned', () => { + expect(cost(findEmailTool, {}, { email: null }).cost).toBe(0) + expect(cost(findEmailTool, {}, {}).cost).toBe(0) + }) +}) + +describe('Datagma enrich person pricing', () => { + it('charges 2 credits on a match without phone', () => { + expect( + cost(enrichPersonTool, {}, { name: 'John Doe', email: 'john@stripe.com', phone: null }).cost + ).toBeCloseTo(2 * DATAGMA_CREDIT_USD) + }) + + it('charges 32 credits (2 + 30) when phone is also found', () => { + expect( + cost( + enrichPersonTool, + {}, + { name: 'John Doe', email: 'john@stripe.com', phone: '+14155551234' } + ).cost + ).toBeCloseTo(32 * DATAGMA_CREDIT_USD) + }) + + it('charges 0 credits on no match', () => { + expect(cost(enrichPersonTool, {}, { name: null, email: null }).cost).toBe(0) + expect(cost(enrichPersonTool, {}, {}).cost).toBe(0) + }) +}) + +describe('Datagma enrich company pricing', () => { + it('charges 2 credits on a match', () => { + expect(cost(enrichCompanyTool, {}, { name: 'Stripe', website: 'stripe.com' }).cost).toBeCloseTo( + 2 * DATAGMA_CREDIT_USD + ) + }) + + it('charges 2 credits when only website is present', () => { + expect(cost(enrichCompanyTool, {}, { name: null, website: 'stripe.com' }).cost).toBeCloseTo( + 2 * DATAGMA_CREDIT_USD + ) + }) + + it('charges 0 credits on no match', () => { + expect(cost(enrichCompanyTool, {}, { name: null, website: null }).cost).toBe(0) + expect(cost(enrichCompanyTool, {}, {}).cost).toBe(0) + }) +}) + +describe('Datagma find phone pricing', () => { + it('charges 30 credits when a phone number is found', () => { + expect(cost(findPhoneTool, {}, { phone: '+14155551234' }).cost).toBeCloseTo( + 30 * DATAGMA_CREDIT_USD + ) + }) + + it('charges 0 credits when no phone is returned', () => { + expect(cost(findPhoneTool, {}, { phone: null }).cost).toBe(0) + expect(cost(findPhoneTool, {}, {}).cost).toBe(0) + }) +}) diff --git a/apps/sim/tools/datagma/enrich_company.ts b/apps/sim/tools/datagma/enrich_company.ts new file mode 100644 index 00000000000..81391e61b32 --- /dev/null +++ b/apps/sim/tools/datagma/enrich_company.ts @@ -0,0 +1,132 @@ +import { datagmaHosting } from '@/tools/datagma/hosting' +import type { + DatagmaEnrichCompanyParams, + DatagmaEnrichCompanyResponse, +} from '@/tools/datagma/types' +import type { ToolConfig } from '@/tools/types' + +/** + * Enrich a company profile from a company domain, name, or SIREN number. + * + * Endpoint: GET https://gateway.datagma.net/api/ingress/v2/full + * Auth: apiId query param + * Docs: https://datagmaapi.readme.io/reference/ingressservice_fullapiv2 + * Pricing: 2 credits per successful response + */ +export const enrichCompanyTool: ToolConfig< + DatagmaEnrichCompanyParams, + DatagmaEnrichCompanyResponse +> = { + id: 'datagma_enrich_company', + name: 'Datagma Enrich Company', + description: + 'Enrich a company profile using a domain, company name, or SIREN number (France). Returns size, industry, revenue, and description. Uses 2 credits per match.', + version: '1.0.0', + + hosting: datagmaHosting((_params, output) => { + const name = output.name as string | null + const website = output.website as string | null + return name || website ? 2 : 0 + }), + + params: { + data: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + "Company domain (e.g., 'stripe.com'), company name, or French SIREN number to enrich", + }, + companyPremium: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Include LinkedIn company data in the response', + }, + companyFull: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Include financial information in the response', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datagma API key', + }, + }, + + request: { + url: (params) => { + const url = new URL('https://gateway.datagma.net/api/ingress/v2/full') + url.searchParams.set('apiId', params.apiKey) + url.searchParams.set('data', params.data) + if (params.companyPremium != null) + url.searchParams.set('companyPremium', String(params.companyPremium)) + if (params.companyFull != null) + url.searchParams.set('companyFull', String(params.companyFull)) + return url.toString() + }, + method: 'GET', + headers: () => ({ Accept: 'application/json' }), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + return { + success: false, + error: + (errorData as Record).message || + `Datagma API error: ${response.status} ${response.statusText}`, + output: { + name: null, + website: null, + industries: null, + companySize: null, + type: null, + founded: null, + shortDescription: null, + revenueRange: null, + headquarters: null, + }, + } + } + const data = (await response.json()) as Record + + // Company data may be nested under a `company` key or returned at the top level + const company = (data.company ?? data) as Record + + return { + success: true, + output: { + name: (company.name as string | null) ?? null, + website: (company.website as string | null) ?? null, + industries: (company.industries as string | null) ?? null, + companySize: (company.companySize as string | null) ?? null, + type: (company.type as string | null) ?? null, + founded: (company.founded as string | null) ?? null, + shortDescription: (company.shortDescription as string | null) ?? null, + revenueRange: (company.revenueRange as string | null) ?? null, + headquarters: (company.headquarters as string | null) ?? null, + }, + } + }, + + outputs: { + name: { type: 'string', description: 'Company name', optional: true }, + website: { type: 'string', description: 'Company website', optional: true }, + industries: { type: 'string', description: 'Industry classification', optional: true }, + companySize: { type: 'string', description: 'Employee headcount range', optional: true }, + type: { type: 'string', description: 'Company type (e.g., Private, Public)', optional: true }, + founded: { type: 'string', description: 'Year founded', optional: true }, + shortDescription: { type: 'string', description: 'Short company description', optional: true }, + revenueRange: { + type: 'string', + description: 'Estimated annual revenue range', + optional: true, + }, + headquarters: { type: 'string', description: 'Headquarters location', optional: true }, + }, +} diff --git a/apps/sim/tools/datagma/enrich_person.ts b/apps/sim/tools/datagma/enrich_person.ts new file mode 100644 index 00000000000..44bae0abc0b --- /dev/null +++ b/apps/sim/tools/datagma/enrich_person.ts @@ -0,0 +1,173 @@ +import { datagmaHosting } from '@/tools/datagma/hosting' +import type { DatagmaEnrichPersonParams, DatagmaEnrichPersonResponse } from '@/tools/datagma/types' +import type { ToolConfig } from '@/tools/types' + +/** + * Enrich a person's profile from an email, LinkedIn URL, or full name + company. + * + * Endpoint: GET https://gateway.datagma.net/api/ingress/v2/full + * Auth: apiId query param + * Docs: https://datagmaapi.readme.io/reference/ingressservice_fullapiv2 + * Pricing: 2 credits per successful response; 30 additional credits when phone is found + */ +export const enrichPersonTool: ToolConfig = + { + id: 'datagma_enrich_person', + name: 'Datagma Enrich Person', + description: + "Enrich a person's profile using their email, LinkedIn URL, or full name and company. Returns job title, company, location, and social data. Uses 2 credits per match; add 30 credits when a phone number is found.", + version: '1.0.0', + + hosting: datagmaHosting((params, output) => { + const name = output.name as string | null + const email = output.email as string | null + if (!name && !email) return 0 + const phoneCredits = output.phone ? 30 : 0 + return 2 + phoneCredits + }), + + params: { + data: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Email address, LinkedIn URL, or full name (use companyKeyword when providing a name)', + }, + companyKeyword: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Company name or keyword to disambiguate when data is a full name', + }, + countryCode: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: "Two-letter country code to improve match accuracy (e.g., 'US', 'GB')", + }, + personFull: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Include education and work history in the response', + }, + phoneFull: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Attempt to find a mobile phone number (costs 30 additional credits if found)', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datagma API key', + }, + }, + + request: { + url: (params) => { + const url = new URL('https://gateway.datagma.net/api/ingress/v2/full') + url.searchParams.set('apiId', params.apiKey) + url.searchParams.set('data', params.data) + if (params.companyKeyword) url.searchParams.set('companyKeyword', params.companyKeyword) + if (params.countryCode) url.searchParams.set('countryCode', params.countryCode) + if (params.personFull != null) url.searchParams.set('personFull', String(params.personFull)) + if (params.phoneFull != null) url.searchParams.set('phoneFull', String(params.phoneFull)) + return url.toString() + }, + method: 'GET', + headers: () => ({ Accept: 'application/json' }), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + return { + success: false, + error: + (errorData as Record).message || + `Datagma API error: ${response.status} ${response.statusText}`, + output: { + name: null, + firstName: null, + lastName: null, + email: null, + emailStatus: null, + jobTitle: null, + company: null, + linkedInUrl: null, + location: null, + country: null, + region: null, + city: null, + extractedRole: null, + extractedSeniority: null, + twitter: null, + phone: null, + personConfidenceScore: null, + }, + } + } + const data = (await response.json()) as Record + + // Datagma nests phone numbers in an array; surface the first number's raw value + const phones = data.phones as Array> | null | undefined + const firstPhone = + Array.isArray(phones) && phones.length > 0 + ? ((phones[0].number as string | null) ?? null) + : null + + return { + success: true, + output: { + name: (data.name as string | null) ?? null, + firstName: (data.firstName as string | null) ?? null, + lastName: (data.lastName as string | null) ?? null, + email: (data.email as string | null) ?? null, + emailStatus: (data.emailStatus as string | null) ?? null, + jobTitle: (data.jobTitle as string | null) ?? null, + company: (data.company as string | null) ?? null, + linkedInUrl: (data.linkedInUrl as string | null) ?? null, + location: (data.location as string | null) ?? null, + country: (data.country as string | null) ?? null, + region: (data.region as string | null) ?? null, + city: (data.city as string | null) ?? null, + extractedRole: (data.extractedRole as string | null) ?? null, + extractedSeniority: (data.extractedSeniority as string | null) ?? null, + twitter: (data.twitter as string | null) ?? null, + phone: firstPhone, + personConfidenceScore: (data.personConfidenceScore as number | null) ?? null, + }, + } + }, + + outputs: { + name: { type: 'string', description: 'Full name', optional: true }, + firstName: { type: 'string', description: 'First name', optional: true }, + lastName: { type: 'string', description: 'Last name', optional: true }, + email: { type: 'string', description: 'Work email address', optional: true }, + emailStatus: { type: 'string', description: 'Email verification status', optional: true }, + jobTitle: { type: 'string', description: 'Current job title', optional: true }, + company: { type: 'string', description: 'Current company name', optional: true }, + linkedInUrl: { type: 'string', description: 'LinkedIn profile URL', optional: true }, + location: { type: 'string', description: 'Location string', optional: true }, + country: { type: 'string', description: 'Country', optional: true }, + region: { type: 'string', description: 'Region/state', optional: true }, + city: { type: 'string', description: 'City', optional: true }, + extractedRole: { type: 'string', description: 'Extracted role category', optional: true }, + extractedSeniority: { + type: 'string', + description: 'Extracted seniority level', + optional: true, + }, + twitter: { type: 'string', description: 'Twitter handle', optional: true }, + phone: { type: 'string', description: 'Mobile phone number', optional: true }, + personConfidenceScore: { + type: 'number', + description: 'Confidence score for the person match (0–1)', + optional: true, + }, + }, + } diff --git a/apps/sim/tools/datagma/find_email.ts b/apps/sim/tools/datagma/find_email.ts new file mode 100644 index 00000000000..22bc38a019b --- /dev/null +++ b/apps/sim/tools/datagma/find_email.ts @@ -0,0 +1,130 @@ +import { datagmaHosting } from '@/tools/datagma/hosting' +import type { DatagmaFindEmailParams, DatagmaFindEmailResponse } from '@/tools/datagma/types' +import type { ToolConfig } from '@/tools/types' + +/** + * Find a verified work email address from a full name and company. + * + * Endpoint: GET https://gateway.datagma.net/api/ingress/v6/findEmail + * Auth: apiId query param + * Docs: https://datagmaapi.readme.io/reference/find-work-email-address + * Pricing: 1 credit per verified email found (no charge for unverified/not found) + */ +export const findEmailTool: ToolConfig = { + id: 'datagma_find_email', + name: 'Datagma Find Email', + description: + "Find a verified work email from a person's full name and company. Uses 1 credit when a verified email is found.", + version: '1.0.0', + + hosting: datagmaHosting((_params, output) => { + const email = output.email as string | null + return email ? 1 : 0 + }), + + params: { + fullName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: "Person's full name (e.g., 'John Doe')", + }, + company: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: "Company name or domain (e.g., 'Stripe' or 'stripe.com')", + }, + linkedInSlug: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'LinkedIn company URL slug to improve match accuracy by 20%+', + }, + findEmailV2Step: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Lookup depth: 3 = full email (default), 2 = domain only', + }, + findEmailV2Country: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: "User's location to improve accuracy (e.g., 'General', 'Japan', 'France')", + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datagma API key', + }, + }, + + request: { + url: (params) => { + const url = new URL('https://gateway.datagma.net/api/ingress/v6/findEmail') + url.searchParams.set('apiId', params.apiKey) + url.searchParams.set('fullName', params.fullName) + url.searchParams.set('company', params.company) + if (params.linkedInSlug) url.searchParams.set('linkedInSlug', params.linkedInSlug) + if (params.findEmailV2Step != null) + url.searchParams.set('findEmailV2Step', String(params.findEmailV2Step)) + if (params.findEmailV2Country) + url.searchParams.set('findEmailV2Country', params.findEmailV2Country) + return url.toString() + }, + method: 'GET', + headers: () => ({ Accept: 'application/json' }), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + return { + success: false, + error: + (errorData as Record).message || + `Datagma API error: ${response.status} ${response.statusText}`, + output: { + email: null, + emailStatus: null, + emailDomain: null, + mxfound: null, + smtpCheck: null, + catchAll: null, + }, + } + } + const data = (await response.json()) as Record + return { + success: true, + output: { + email: (data.email as string | null) ?? null, + emailStatus: (data.status as string | null) ?? null, + emailDomain: (data.emailDomain as string | null) ?? null, + mxfound: (data.mxfound as boolean | null) ?? null, + smtpCheck: (data.smtpCheck as boolean | null) ?? null, + // Datagma API spells this field "cachAll" (their documented typo); read both to be safe + catchAll: (data.cachAll as boolean | null) ?? (data.catchAll as boolean | null) ?? null, + }, + } + }, + + outputs: { + email: { type: 'string', description: 'Verified work email address', optional: true }, + emailStatus: { + type: 'string', + description: 'Email verification status (e.g., valid, invalid)', + optional: true, + }, + emailDomain: { type: 'string', description: 'Email domain', optional: true }, + mxfound: { type: 'boolean', description: 'Whether MX records were found', optional: true }, + smtpCheck: { + type: 'boolean', + description: 'Whether SMTP validation succeeded', + optional: true, + }, + catchAll: { type: 'boolean', description: 'Whether the domain is catch-all', optional: true }, + }, +} diff --git a/apps/sim/tools/datagma/find_phone.ts b/apps/sim/tools/datagma/find_phone.ts new file mode 100644 index 00000000000..7d3b019f0e8 --- /dev/null +++ b/apps/sim/tools/datagma/find_phone.ts @@ -0,0 +1,111 @@ +import { datagmaHosting } from '@/tools/datagma/hosting' +import type { DatagmaFindPhoneParams, DatagmaFindPhoneResponse } from '@/tools/datagma/types' +import type { ToolConfig } from '@/tools/types' + +/** + * Find a mobile phone number from a LinkedIn URL (and optional email). + * + * Endpoint: GET https://gateway.datagma.net/api/ingress/v1/search + * Auth: apiId query param + * Docs: https://datagmaapi.readme.io/reference/find-a-phone-number + * Pricing: 30 credits per phone number found (same credit unit as email; 1 email = 1 credit) + * Pricing source: https://datagma.com/pricing ("30 credits = 1 mobile phone number") + */ +export const findPhoneTool: ToolConfig = { + id: 'datagma_find_phone', + name: 'Datagma Find Phone', + description: + "Find a mobile phone number from a person's LinkedIn URL. Optionally supply an email to improve match accuracy. Uses 30 credits when a number is found.", + version: '1.0.0', + + hosting: datagmaHosting((_params, output) => { + const phone = output.phone as string | null + return phone ? 30 : 0 + }), + + params: { + username: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: "LinkedIn URL of the person (e.g., 'https://linkedin.com/in/johndoe')", + }, + email: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Email address to improve phone match accuracy', + }, + minimumMatch: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Minimum match confidence threshold (0–1; default 1 for highest precision)', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datagma API key', + }, + }, + + request: { + url: (params) => { + const url = new URL('https://gateway.datagma.net/api/ingress/v1/search') + url.searchParams.set('apiId', params.apiKey) + url.searchParams.set('username', params.username) + if (params.email) url.searchParams.set('email', params.email) + if (params.minimumMatch != null) + url.searchParams.set('minimumMatch', String(params.minimumMatch)) + // Always request WhatsApp verification since we surface isWhatsapp in the output + url.searchParams.set('whatsappCheck', 'true') + return url.toString() + }, + method: 'GET', + headers: () => ({ Accept: 'application/json' }), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + return { + success: false, + error: + (errorData as Record).message || + `Datagma API error: ${response.status} ${response.statusText}`, + output: { phone: null, countryCode: null, isWhatsapp: null }, + } + } + const data = (await response.json()) as Record + + // Phone data may be nested under a `phones` array or returned at top level + const phones = data.phones as Array> | null | undefined + const firstPhone = Array.isArray(phones) && phones.length > 0 ? phones[0] : null + + return { + success: true, + output: { + phone: firstPhone + ? ((firstPhone.number as string | null) ?? null) + : ((data.phone as string | null) ?? null), + countryCode: firstPhone + ? ((firstPhone.countryCode as string | null) ?? null) + : ((data.countryCode as string | null) ?? null), + isWhatsapp: firstPhone + ? ((firstPhone.isWhatsapp as boolean | null) ?? null) + : ((data.isWhatsapp as boolean | null) ?? null), + }, + } + }, + + outputs: { + phone: { type: 'string', description: 'Mobile phone number', optional: true }, + countryCode: { type: 'string', description: 'Country code prefix (e.g., +1)', optional: true }, + isWhatsapp: { + type: 'boolean', + description: 'Whether the number is linked to WhatsApp', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/datagma/get_credits.ts b/apps/sim/tools/datagma/get_credits.ts new file mode 100644 index 00000000000..fecedc68aac --- /dev/null +++ b/apps/sim/tools/datagma/get_credits.ts @@ -0,0 +1,61 @@ +import type { DatagmaGetCreditsParams, DatagmaGetCreditsResponse } from '@/tools/datagma/types' +import type { ToolConfig } from '@/tools/types' + +/** + * Check the remaining credit balance on a Datagma account. + * + * Endpoint: GET https://gateway.datagma.net/api/ingress/v1/mine + * Auth: apiId query param + * Docs: https://datagmaapi.readme.io/reference/ingressservice_getcredit + * Pricing: free (no credits consumed) + */ +export const getCreditsTool: ToolConfig = { + id: 'datagma_get_credits', + name: 'Datagma Get Credits', + description: 'Check remaining credit balance on a Datagma account. Free — no credits consumed.', + version: '1.0.0', + + // No hosting config — credit-balance lookup is free and should always use BYOK + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datagma API key', + }, + }, + + request: { + url: (params) => { + const url = new URL('https://gateway.datagma.net/api/ingress/v1/mine') + url.searchParams.set('apiId', params.apiKey) + return url.toString() + }, + method: 'GET', + headers: () => ({ Accept: 'application/json' }), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + return { + success: false, + error: + (errorData as Record).message || + `Datagma API error: ${response.status} ${response.statusText}`, + output: { credits: null }, + } + } + const data = (await response.json()) as Record + return { + success: true, + output: { + credits: (data.credit as number | null) ?? (data.credits as number | null) ?? null, + }, + } + }, + + outputs: { + credits: { type: 'number', description: 'Remaining Datagma credits', optional: true }, + }, +} diff --git a/apps/sim/tools/datagma/hosting.ts b/apps/sim/tools/datagma/hosting.ts new file mode 100644 index 00000000000..f09cdecd910 --- /dev/null +++ b/apps/sim/tools/datagma/hosting.ts @@ -0,0 +1,43 @@ +import type { ToolHostingConfig } from '@/tools/types' + +/** + * Env var prefix for Datagma hosted keys. Provide keys as + * `DATAGMA_API_KEY_COUNT` plus `DATAGMA_API_KEY_1..N`. + */ +export const DATAGMA_API_KEY_PREFIX = 'DATAGMA_API_KEY' + +/** + * Dollar cost of a single Datagma credit. + * + * Based on the Datagma Popular plan ($99/month, 7,500 credits ≈ $0.0132/credit). + * Email finder: 1 credit per verified email. Phone finder: 30 credits per mobile. + * Enrichment: 2 credits per successful response. + * Pricing source: https://datagma.com/pricing + */ +export const DATAGMA_CREDIT_USD = 0.0132 + +/** + * Build a Datagma `hosting` config. `getCredits` returns the number of Datagma + * credits the call consumed, derived from the tool's output (per the documented + * per-endpoint credit model at https://datagmaapi.readme.io/reference/getting-started-with-your-api). + */ +export function datagmaHosting

( + getCredits: (params: P, output: Record) => number +): ToolHostingConfig

{ + return { + envKeyPrefix: DATAGMA_API_KEY_PREFIX, + apiKeyParam: 'apiKey', + byokProviderId: 'datagma', + pricing: { + type: 'custom', + getCost: (params, output) => { + const credits = getCredits(params, output) + return { cost: credits * DATAGMA_CREDIT_USD, metadata: { credits } } + }, + }, + rateLimit: { + mode: 'per_request', + requestsPerMinute: 60, + }, + } +} diff --git a/apps/sim/tools/datagma/index.ts b/apps/sim/tools/datagma/index.ts new file mode 100644 index 00000000000..47c4abb90ef --- /dev/null +++ b/apps/sim/tools/datagma/index.ts @@ -0,0 +1,13 @@ +export * from './types' + +import { enrichCompanyTool } from '@/tools/datagma/enrich_company' +import { enrichPersonTool } from '@/tools/datagma/enrich_person' +import { findEmailTool } from '@/tools/datagma/find_email' +import { findPhoneTool } from '@/tools/datagma/find_phone' +import { getCreditsTool } from '@/tools/datagma/get_credits' + +export const datagmaEnrichCompanyTool = enrichCompanyTool +export const datagmaEnrichPersonTool = enrichPersonTool +export const datagmaFindEmailTool = findEmailTool +export const datagmaFindPhoneTool = findPhoneTool +export const datagmaGetCreditsTool = getCreditsTool diff --git a/apps/sim/tools/datagma/types.ts b/apps/sim/tools/datagma/types.ts new file mode 100644 index 00000000000..c018eb3e4e4 --- /dev/null +++ b/apps/sim/tools/datagma/types.ts @@ -0,0 +1,151 @@ +import type { ToolResponse } from '@/tools/types' + +interface DatagmaBaseParams { + apiKey: string +} + +// --------------------------------------------------------------------------- +// Find Email (findEmail) +// Endpoint: GET https://gateway.datagma.net/api/ingress/v6/findEmail +// Auth: apiId query param +// Docs: https://datagmaapi.readme.io/reference/find-work-email-address +// --------------------------------------------------------------------------- + +export interface DatagmaFindEmailParams extends DatagmaBaseParams { + fullName: string + company: string + linkedInSlug?: string + findEmailV2Step?: number + findEmailV2Country?: string +} + +export interface DatagmaFindEmailResponse extends ToolResponse { + output: { + email: string | null + emailStatus: string | null + emailDomain: string | null + mxfound: boolean | null + smtpCheck: boolean | null + catchAll: boolean | null + } +} + +// --------------------------------------------------------------------------- +// Enrich Person +// Endpoint: GET https://gateway.datagma.net/api/ingress/v2/full +// Auth: apiId query param +// Docs: https://datagmaapi.readme.io/reference/ingressservice_fullapiv2 +// Pricing: 2 credits per successful response +// --------------------------------------------------------------------------- + +export interface DatagmaEnrichPersonParams extends DatagmaBaseParams { + /** Email address, LinkedIn URL, or full name (use with companyKeyword) */ + data: string + companyKeyword?: string + countryCode?: string + personFull?: boolean + phoneFull?: boolean +} + +export interface DatagmaEnrichPersonResponse extends ToolResponse { + output: { + name: string | null + firstName: string | null + lastName: string | null + email: string | null + emailStatus: string | null + jobTitle: string | null + company: string | null + linkedInUrl: string | null + location: string | null + country: string | null + region: string | null + city: string | null + extractedRole: string | null + extractedSeniority: string | null + twitter: string | null + phone: string | null + personConfidenceScore: number | null + } +} + +// --------------------------------------------------------------------------- +// Enrich Company (via full endpoint with company domain/name) +// Endpoint: GET https://gateway.datagma.net/api/ingress/v2/full +// Auth: apiId query param +// Docs: https://datagmaapi.readme.io/reference/ingressservice_fullapiv2 +// Pricing: 2 credits per successful response +// --------------------------------------------------------------------------- + +export interface DatagmaEnrichCompanyParams extends DatagmaBaseParams { + /** Company domain, name, or SIREN number */ + data: string + companyPremium?: boolean + companyFull?: boolean +} + +export interface DatagmaEnrichCompanyResponse extends ToolResponse { + output: { + name: string | null + website: string | null + industries: string | null + companySize: string | null + type: string | null + founded: string | null + shortDescription: string | null + revenueRange: string | null + headquarters: string | null + } +} + +// --------------------------------------------------------------------------- +// Find Phone (via search endpoint or enrich with phoneFull) +// Endpoint: GET https://gateway.datagma.net/api/ingress/v1/search +// Auth: apiId query param +// Docs: https://datagmaapi.readme.io/reference/find-a-phone-number +// Pricing: 30 credits per phone number found (1 credit = 1 email) +// --------------------------------------------------------------------------- + +export interface DatagmaFindPhoneParams extends DatagmaBaseParams { + /** LinkedIn URL of the person */ + username: string + /** Email address to improve match accuracy */ + email?: string + /** Minimum match confidence (0–1, default 1) */ + minimumMatch?: number +} + +export interface DatagmaFindPhoneResponse extends ToolResponse { + output: { + phone: string | null + countryCode: string | null + isWhatsapp: boolean | null + } +} + +// --------------------------------------------------------------------------- +// Get Credits +// Endpoint: GET https://gateway.datagma.net/api/ingress/v1/mine +// Auth: apiId query param +// Docs: https://datagmaapi.readme.io/reference/ingressservice_getcredit +// Pricing: free (no credit consumed) +// --------------------------------------------------------------------------- + +export interface DatagmaGetCreditsParams extends DatagmaBaseParams {} + +export interface DatagmaGetCreditsResponse extends ToolResponse { + output: { + credits: number | null + } +} + +// --------------------------------------------------------------------------- +// Union of all response types +// --------------------------------------------------------------------------- + +export type DatagmaResponse = + | DatagmaFindEmailResponse + | DatagmaEnrichPersonResponse + | DatagmaEnrichCompanyResponse + | DatagmaFindPhoneResponse + | DatagmaGetCreditsResponse diff --git a/apps/sim/tools/dropcontact-hosting.test.ts b/apps/sim/tools/dropcontact-hosting.test.ts new file mode 100644 index 00000000000..02d92b02c39 --- /dev/null +++ b/apps/sim/tools/dropcontact-hosting.test.ts @@ -0,0 +1,181 @@ +/** + * @vitest-environment node + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { dropcontactEnrichContactTool } from '@/tools/dropcontact/enrich_contact' +import { DROPCONTACT_CREDIT_USD } from '@/tools/dropcontact/hosting' +import type { ToolConfig } from '@/tools/types' + +afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +function cost(tool: ToolConfig, params: any, output: Record) { + const pricing = tool.hosting?.pricing + if (!pricing || pricing.type !== 'custom') throw new Error('Expected custom pricing') + const result = pricing.getCost(params, output) + return typeof result === 'number' ? { cost: result } : result +} + +describe('Dropcontact hosted key config', () => { + it('declares hosting with the correct env prefix and BYOK provider ID', () => { + expect(dropcontactEnrichContactTool.hosting?.envKeyPrefix).toBe('DROPCONTACT_API_KEY') + expect(dropcontactEnrichContactTool.hosting?.byokProviderId).toBe('dropcontact') + }) +}) + +describe('Dropcontact hosted key pricing', () => { + it('charges 1 credit when email_found is true', () => { + expect( + cost(dropcontactEnrichContactTool, {}, { email_found: true, email: 'a@b.com' }).cost + ).toBeCloseTo(DROPCONTACT_CREDIT_USD) + }) + + it('charges 0 credits when email_found is false', () => { + expect(cost(dropcontactEnrichContactTool, {}, { email_found: false, email: null }).cost).toBe(0) + }) + + it('charges 0 credits when email_found is undefined/missing', () => { + expect(cost(dropcontactEnrichContactTool, {}, {}).cost).toBe(0) + }) +}) + +describe('Dropcontact postProcess polls to completion', () => { + it('polls the enrich endpoint until success:true and resolves the final output', async () => { + vi.useFakeTimers() + + // Mock: first call returns success:false (pending), second returns success:true (ready) + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: false, + success: false, + reason: 'Request not ready yet, try again in 30 seconds', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: false, + success: true, + data: [ + { + civility: 'Mr', + first_name: 'John', + last_name: 'Doe', + full_name: 'John Doe', + email: [{ email: 'john.doe@acme.com', qualification: 'nominative@pro' }], + phone: null, + mobile_phone: null, + company: 'Acme Corp', + website: 'acme.com', + company_linkedin: null, + linkedin: 'https://linkedin.com/in/johndoe', + siren: null, + siret: null, + siret_address: null, + vat: null, + nb_employees: '50-100', + naf5_code: null, + naf5_des: null, + industry: 'Software', + job: 'Software Engineer', + job_level: 'Senior', + job_function: 'Engineering', + company_turnover: null, + company_results: null, + }, + ], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ) + vi.stubGlobal('fetch', fetchMock) + + const initial = { + success: true as const, + output: { request_id: 'req_abc123' } as any, + } + const promise = dropcontactEnrichContactTool.postProcess!( + initial as any, + { apiKey: 'test-key' } as any, + vi.fn() + ) + + // Advance past two poll intervals + await vi.advanceTimersByTimeAsync(5000) + await vi.advanceTimersByTimeAsync(5000) + + const result = await promise + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.dropcontact.com/v1/enrich/all/req_abc123', + expect.objectContaining({ + headers: expect.objectContaining({ 'X-Access-Token': 'test-key' }), + }) + ) + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(result.success).toBe(true) + expect((result.output as any).email).toBe('john.doe@acme.com') + expect((result.output as any).email_found).toBe(true) + expect((result.output as any).qualification).toBe('nominative@pro') + expect((result.output as any).first_name).toBe('John') + expect((result.output as any).company).toBe('Acme Corp') + expect((result.output as any).request_id).toBe('req_abc123') + }) + + it('throws if no request_id is present in the initial result', async () => { + const initial = { + success: true as const, + output: { request_id: null } as any, + } + await expect( + dropcontactEnrichContactTool.postProcess!(initial as any, { apiKey: 'k' } as any, vi.fn()) + ).rejects.toThrow('request_id') + }) + + it('throws if enrichment does not complete within the polling window', async () => { + vi.useFakeTimers() + + // Always returns pending — use a factory so each call gets a fresh Response body + const fetchMock = vi.fn().mockImplementation(() => + Promise.resolve( + new Response( + JSON.stringify({ + error: false, + success: false, + reason: 'Request not ready yet, try again in 30 seconds', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ) + ) + vi.stubGlobal('fetch', fetchMock) + + const initial = { + success: true as const, + output: { request_id: 'req_timeout' } as any, + } + + let rejection: unknown + const promise = dropcontactEnrichContactTool.postProcess!( + initial as any, + { apiKey: 'k' } as any, + vi.fn() + ).catch((err) => { + rejection = err + }) + + // Advance past MAX_POLL_TIME_MS (120000ms) + await vi.advanceTimersByTimeAsync(125000) + await promise + + expect(rejection).toBeInstanceOf(Error) + expect((rejection as Error).message).toMatch(/polling window/) + }) +}) diff --git a/apps/sim/tools/dropcontact/enrich_contact.ts b/apps/sim/tools/dropcontact/enrich_contact.ts new file mode 100644 index 00000000000..a82322a0ad4 --- /dev/null +++ b/apps/sim/tools/dropcontact/enrich_contact.ts @@ -0,0 +1,368 @@ +import { sleep } from '@sim/utils/helpers' +import { dropcontactHosting } from '@/tools/dropcontact/hosting' +import type { + DropcontactEmailEntry, + DropcontactEnrichContactParams, + DropcontactEnrichContactResponse, + DropcontactEnrichedContact, +} from '@/tools/dropcontact/types' +import type { ToolConfig } from '@/tools/types' + +const POLL_INTERVAL_MS = 5000 +const MAX_POLL_TIME_MS = 120000 + +/** + * Map the first contact from the Dropcontact poll result data array to the + * flat tool output shape. + * + * @param contact - Raw contact object from the Dropcontact API poll response + * @returns Structured output matching `DropcontactEnrichContactResponse.output` + */ +function mapContactData( + requestId: string | null, + contact: DropcontactEnrichedContact +): DropcontactEnrichContactResponse['output'] { + const emailEntries = Array.isArray(contact.email) + ? (contact.email as DropcontactEmailEntry[]) + : null + const firstEmail = emailEntries?.[0] ?? null + + return { + request_id: requestId, + email_found: Boolean(firstEmail?.email), + email: firstEmail?.email ?? null, + emails: emailEntries, + qualification: firstEmail?.qualification ?? null, + first_name: contact.first_name ?? null, + last_name: contact.last_name ?? null, + full_name: contact.full_name ?? null, + civility: contact.civility ?? null, + phone: contact.phone ?? null, + mobile_phone: contact.mobile_phone ?? null, + company: contact.company ?? null, + website: contact.website ?? null, + company_linkedin: contact.company_linkedin ?? null, + linkedin: contact.linkedin ?? null, + country: contact.country ?? null, + siren: contact.siren ?? null, + siret: contact.siret ?? null, + siret_address: contact.siret_address ?? null, + siret_zip: contact.siret_zip ?? null, + siret_city: contact.siret_city ?? null, + vat: contact.vat ?? null, + nb_employees: contact.nb_employees ?? null, + employee_count: contact.employee_count ?? null, + naf5_code: contact.naf5_code ?? null, + naf5_des: contact.naf5_des ?? null, + industry: contact.industry ?? null, + job: contact.job ?? null, + job_level: contact.job_level ?? null, + job_function: contact.job_function ?? null, + company_turnover: contact.company_turnover ?? null, + company_results: contact.company_results ?? null, + } +} + +export const dropcontactEnrichContactTool: ToolConfig< + DropcontactEnrichContactParams, + DropcontactEnrichContactResponse +> = { + id: 'dropcontact_enrich_contact', + name: 'Dropcontact Enrich Contact', + description: + 'Enrich a contact with verified B2B email, phone, company data, and LinkedIn info via Dropcontact. Submits an async enrichment request, then polls until the result is ready (up to 2 minutes). Charges 1 credit only when a verified email is returned. Provide at least one of: email, first_name+last_name+company, full_name+company, or linkedin URL.', + version: '1.0.0', + + hosting: dropcontactHosting((_params, output) => { + // 1 credit per contact when a verified email is found. + // Source: https://developer.dropcontact.com (retrieved 2026-05) + return output.email_found === true ? 1 : 0 + }), + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Dropcontact API key (X-Access-Token)', + }, + email: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Email address of the contact to enrich', + }, + first_name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'First name of the contact', + }, + last_name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Last name of the contact', + }, + full_name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Full name (alternative to first_name + last_name)', + }, + company: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Company name', + }, + website: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Company website (e.g. acme.com)', + }, + num_siren: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'French company SIREN number', + }, + phone: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Phone number', + }, + linkedin: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'LinkedIn profile URL', + }, + country: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Country code (ISO 3166-1 alpha-2, e.g. "US", "FR")', + }, + siren: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Include SIREN/SIRET enrichment (France only)', + }, + language: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Language for returned data (e.g. "en", "fr")', + }, + }, + + request: { + // Submit endpoint: POST https://api.dropcontact.com/v1/enrich/all + // Source: https://developer.dropcontact.com (retrieved 2026-05) + url: 'https://api.dropcontact.com/v1/enrich/all', + method: 'POST', + headers: (params: DropcontactEnrichContactParams) => ({ + 'X-Access-Token': params.apiKey, + 'Content-Type': 'application/json', + }), + body: (params: DropcontactEnrichContactParams) => { + const contact: Record = {} + if (params.email) contact.email = params.email + if (params.first_name) contact.first_name = params.first_name + if (params.last_name) contact.last_name = params.last_name + if (params.full_name) contact.full_name = params.full_name + if (params.company) contact.company = params.company + if (params.website) contact.website = params.website + if (params.num_siren) contact.num_siren = params.num_siren + if (params.phone) contact.phone = params.phone + if (params.linkedin) contact.linkedin = params.linkedin + if (params.country) contact.country = params.country + + const body: Record = { data: [contact] } + if (params.siren !== undefined) body.siren = params.siren + if (params.language) body.language = params.language + + return body + }, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Dropcontact API error: ${response.status} - ${errorText}`) + } + const json = await response.json() + if (json.error) { + throw new Error(`Dropcontact API error: ${String(json.reason ?? json.error)}`) + } + // Submit response includes request_id; enrichment is async + return { + success: true, + output: { + request_id: (json.request_id as string) ?? null, + email_found: false, + email: null, + emails: null, + qualification: null, + first_name: null, + last_name: null, + full_name: null, + civility: null, + phone: null, + mobile_phone: null, + company: null, + website: null, + company_linkedin: null, + linkedin: null, + country: null, + siren: null, + siret: null, + siret_address: null, + siret_zip: null, + siret_city: null, + vat: null, + nb_employees: null, + employee_count: null, + naf5_code: null, + naf5_des: null, + industry: null, + job: null, + job_level: null, + job_function: null, + company_turnover: null, + company_results: null, + }, + } + }, + + postProcess: async (result, params) => { + if (!result.success) return result + + const requestId = result.output.request_id + if (!requestId) { + throw new Error('Dropcontact enrichment did not return a request_id') + } + + let elapsedTime = 0 + while (elapsedTime < MAX_POLL_TIME_MS) { + await sleep(POLL_INTERVAL_MS) + elapsedTime += POLL_INTERVAL_MS + + // Poll endpoint: GET https://api.dropcontact.com/v1/enrich/all/{request_id} + // Source: https://developer.dropcontact.com (retrieved 2026-05) + const pollResponse = await fetch( + `https://api.dropcontact.com/v1/enrich/all/${encodeURIComponent(requestId)}`, + { + headers: { + 'X-Access-Token': params.apiKey, + 'Content-Type': 'application/json', + }, + } + ) + + if (!pollResponse.ok) { + const errorText = await pollResponse.text() + throw new Error(`Dropcontact poll error: ${pollResponse.status} - ${errorText}`) + } + + const json = await pollResponse.json() + + // Error state: { error: true|string, reason?: string } + if (json.error) { + throw new Error(`Dropcontact enrichment failed: ${String(json.reason ?? json.error)}`) + } + + // Pending: { success: false, error: false, reason: "Request not ready yet..." } + if (!json.success) continue + + // Ready: { success: true, data: [...], error: false } + + const contacts = Array.isArray(json.data) ? json.data : [] + const contact = (contacts[0] ?? {}) as DropcontactEnrichedContact + + return { + success: true, + output: mapContactData(requestId, contact), + } + } + + throw new Error('Dropcontact enrichment did not complete within the polling window') + }, + + outputs: { + request_id: { type: 'string', description: 'Dropcontact async request ID', optional: true }, + email_found: { type: 'boolean', description: 'Whether a verified email was found' }, + email: { type: 'string', description: 'Primary verified email address', optional: true }, + emails: { + type: 'array', + description: 'All email addresses returned (each with email and qualification)', + optional: true, + items: { + type: 'object', + properties: { + email: { type: 'string', description: 'Email address' }, + qualification: { + type: 'string', + description: 'Email qualification (e.g. nominative@pro)', + }, + }, + }, + }, + qualification: { + type: 'string', + description: 'Primary email qualification (e.g. nominative@pro, catch_all@pro)', + optional: true, + }, + first_name: { type: 'string', description: 'First name', optional: true }, + last_name: { type: 'string', description: 'Last name', optional: true }, + full_name: { type: 'string', description: 'Full name', optional: true }, + civility: { type: 'string', description: 'Civility (Mr, Mrs, etc.)', optional: true }, + phone: { type: 'string', description: 'Phone number', optional: true }, + mobile_phone: { type: 'string', description: 'Mobile phone number', optional: true }, + company: { type: 'string', description: 'Company name', optional: true }, + website: { type: 'string', description: 'Company website', optional: true }, + company_linkedin: { type: 'string', description: 'Company LinkedIn URL', optional: true }, + linkedin: { type: 'string', description: 'Personal LinkedIn URL', optional: true }, + country: { type: 'string', description: 'Country code (ISO 3166-1 alpha-2)', optional: true }, + siren: { type: 'string', description: 'French SIREN number', optional: true }, + siret: { type: 'string', description: 'French SIRET number', optional: true }, + siret_address: { type: 'string', description: 'SIRET registered address', optional: true }, + siret_zip: { type: 'string', description: 'SIRET registered postal code', optional: true }, + siret_city: { type: 'string', description: 'SIRET registered city', optional: true }, + vat: { type: 'string', description: 'VAT number', optional: true }, + nb_employees: { type: 'string', description: 'Employee count range', optional: true }, + employee_count: { + type: 'number', + description: 'Exact employee count (Growth plan and above)', + optional: true, + }, + naf5_code: { type: 'string', description: 'NAF/APE code (France)', optional: true }, + naf5_des: { + type: 'string', + description: 'NAF/APE code description (France)', + optional: true, + }, + industry: { type: 'string', description: 'Industry classification', optional: true }, + job: { type: 'string', description: 'Job title', optional: true }, + job_level: { + type: 'string', + description: 'Job seniority level (e.g. C-level, Director)', + optional: true, + }, + job_function: { + type: 'string', + description: 'Job function (e.g. Sales, Engineering)', + optional: true, + }, + company_turnover: { + type: 'string', + description: 'Company revenue/turnover range', + optional: true, + }, + company_results: { type: 'string', description: 'Company net results', optional: true }, + }, +} diff --git a/apps/sim/tools/dropcontact/hosting.ts b/apps/sim/tools/dropcontact/hosting.ts new file mode 100644 index 00000000000..6ba68ad91df --- /dev/null +++ b/apps/sim/tools/dropcontact/hosting.ts @@ -0,0 +1,49 @@ +import type { ToolHostingConfig } from '@/tools/types' + +/** + * Env var prefix for Dropcontact hosted keys. Provide keys as + * `DROPCONTACT_API_KEY_COUNT` plus `DROPCONTACT_API_KEY_1..N`. + */ +export const DROPCONTACT_API_KEY_PREFIX = 'DROPCONTACT_API_KEY' + +/** + * Dollar cost of a single Dropcontact credit. + * + * Dropcontact's Starter plan is €79/month for 500 credits (≈ $0.158/credit at + * parity). Credits are only deducted when a verified business email is + * successfully returned; no charge if no email is found. + * + * Pricing source: https://www.dropcontact.com/pricing (retrieved 2026-05) + * + * NOTE: This is an approximation based on the Starter plan rate. Actual + * per-credit cost varies by plan tier and currency. A human should verify + * before deploying hosted-key billing. + */ +export const DROPCONTACT_CREDIT_USD = 0.17 + +/** + * Build a Dropcontact `hosting` config. `getCredits` returns the number of + * Dropcontact credits the call consumed, derived from the tool's final output. + */ +export function dropcontactHosting

( + getCredits: (params: P, output: Record) => number +): ToolHostingConfig

{ + return { + envKeyPrefix: DROPCONTACT_API_KEY_PREFIX, + apiKeyParam: 'apiKey', + byokProviderId: 'dropcontact', + pricing: { + type: 'custom', + getCost: (params, output) => { + const credits = getCredits(params, output) + return { cost: credits * DROPCONTACT_CREDIT_USD, metadata: { credits } } + }, + }, + rateLimit: { + // Dropcontact rate limit: 60 requests per second = 3600 requests per minute + // Source: https://developer.dropcontact.com (retrieved 2026-05) + mode: 'per_request', + requestsPerMinute: 3600, + }, + } +} diff --git a/apps/sim/tools/dropcontact/index.ts b/apps/sim/tools/dropcontact/index.ts new file mode 100644 index 00000000000..055aed48524 --- /dev/null +++ b/apps/sim/tools/dropcontact/index.ts @@ -0,0 +1,5 @@ +export * from './types' + +import { dropcontactEnrichContactTool } from '@/tools/dropcontact/enrich_contact' + +export { dropcontactEnrichContactTool } diff --git a/apps/sim/tools/dropcontact/types.ts b/apps/sim/tools/dropcontact/types.ts new file mode 100644 index 00000000000..34d2fec5f07 --- /dev/null +++ b/apps/sim/tools/dropcontact/types.ts @@ -0,0 +1,140 @@ +import type { OutputProperty, ToolResponse } from '@/tools/types' + +export interface DropcontactBaseParams { + apiKey: string +} + +// --------------------------------------------------------------------------- +// Shared output property constants +// --------------------------------------------------------------------------- + +export const DROPCONTACT_EMAIL_ITEM_OUTPUT_PROPERTIES = { + email: { type: 'string', description: 'Email address' }, + qualification: { + type: 'string', + description: + 'Email qualification in the format @, e.g. nominative@pro, catch_all@pro, generic@perso', + }, +} as const satisfies Record + +export const DROPCONTACT_EMAILS_OUTPUT: OutputProperty = { + type: 'array', + description: 'All email addresses found for the contact', + items: { + type: 'object', + properties: DROPCONTACT_EMAIL_ITEM_OUTPUT_PROPERTIES, + }, +} + +// --------------------------------------------------------------------------- +// Enrich Contact (single-contact async enrichment) +// --------------------------------------------------------------------------- + +export interface DropcontactEnrichContactParams extends DropcontactBaseParams { + /** Email address of the contact to enrich */ + email?: string + /** First name of the contact */ + first_name?: string + /** Last name of the contact */ + last_name?: string + /** Full name (alternative to first_name + last_name) */ + full_name?: string + /** Company name */ + company?: string + /** Company website (e.g. acme.com) */ + website?: string + /** French company SIREN number */ + num_siren?: string + /** Phone number */ + phone?: string + /** LinkedIn profile URL */ + linkedin?: string + /** Country code (ISO 3166-1 alpha-2) */ + country?: string + /** Whether to include SIREN/SIRET enrichment (France only) */ + siren?: boolean + /** Language for returned data (e.g. "en", "fr") */ + language?: string +} + +/** Per-contact email entry returned by the Dropcontact API */ +export interface DropcontactEmailEntry { + email: string + qualification: string +} + +/** Enriched contact data returned in the poll result */ +export interface DropcontactEnrichedContact { + civility: string | null + first_name: string | null + last_name: string | null + full_name: string | null + email: DropcontactEmailEntry[] | null + phone: string | null + mobile_phone: string | null + company: string | null + website: string | null + company_linkedin: string | null + linkedin: string | null + country: string | null + siren: string | null + siret: string | null + siret_address: string | null + siret_zip: string | null + siret_city: string | null + vat: string | null + nb_employees: string | null + employee_count: number | null + naf5_code: string | null + naf5_des: string | null + industry: string | null + job: string | null + job_level: string | null + job_function: string | null + company_turnover: string | null + company_results: string | null +} + +export interface DropcontactEnrichContactResponse extends ToolResponse { + output: { + request_id: string | null + /** Whether the enrichment returned a verified email */ + email_found: boolean + /** First verified email address, if any */ + email: string | null + /** All emails returned by Dropcontact */ + emails: DropcontactEmailEntry[] | null + /** Email qualification (e.g. nominative@pro) */ + qualification: string | null + first_name: string | null + last_name: string | null + full_name: string | null + civility: string | null + phone: string | null + mobile_phone: string | null + company: string | null + website: string | null + company_linkedin: string | null + linkedin: string | null + country: string | null + siren: string | null + siret: string | null + siret_address: string | null + siret_zip: string | null + siret_city: string | null + vat: string | null + nb_employees: string | null + employee_count: number | null + naf5_code: string | null + naf5_des: string | null + industry: string | null + job: string | null + job_level: string | null + job_function: string | null + company_turnover: string | null + company_results: string | null + } +} + +/** Discriminated union of all Dropcontact tool responses */ +export type DropcontactResponse = DropcontactEnrichContactResponse diff --git a/apps/sim/tools/enrow-hosting.test.ts b/apps/sim/tools/enrow-hosting.test.ts new file mode 100644 index 00000000000..f0836221e6f --- /dev/null +++ b/apps/sim/tools/enrow-hosting.test.ts @@ -0,0 +1,162 @@ +/** + * @vitest-environment node + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { enrowFindEmailTool } from '@/tools/enrow/find_email' +import { ENROW_CREDIT_USD } from '@/tools/enrow/hosting' +import { enrowVerifyEmailTool } from '@/tools/enrow/verify_email' +import type { ToolConfig } from '@/tools/types' + +afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +function cost( + tool: ToolConfig, + params: unknown, + output: Record +) { + const pricing = tool.hosting?.pricing + if (!pricing || pricing.type !== 'custom') throw new Error('Expected custom pricing') + const result = pricing.getCost(params, output) + return typeof result === 'number' ? { cost: result } : result +} + +describe('Enrow hosted key config', () => { + it('declares the correct env key prefix and BYOK provider for find_email', () => { + expect(enrowFindEmailTool.hosting?.envKeyPrefix).toBe('ENROW_API_KEY') + expect(enrowFindEmailTool.hosting?.byokProviderId).toBe('enrow') + }) + + it('declares the correct env key prefix and BYOK provider for verify_email', () => { + expect(enrowVerifyEmailTool.hosting?.envKeyPrefix).toBe('ENROW_API_KEY') + expect(enrowVerifyEmailTool.hosting?.byokProviderId).toBe('enrow') + }) +}) + +describe('Enrow find_email pricing', () => { + it('charges 1 credit when qualification is valid', () => { + expect(cost(enrowFindEmailTool, {}, { qualification: 'valid' }).cost).toBeCloseTo( + 1 * ENROW_CREDIT_USD + ) + }) + + it('charges 0 credits when qualification is invalid', () => { + expect(cost(enrowFindEmailTool, {}, { qualification: 'invalid' }).cost).toBe(0) + }) + + it('charges 0 credits when qualification is null (no result)', () => { + expect(cost(enrowFindEmailTool, {}, { qualification: null }).cost).toBe(0) + }) +}) + +describe('Enrow verify_email pricing', () => { + it('charges 0.25 credits per verification regardless of result', () => { + expect(cost(enrowVerifyEmailTool, {}, { qualification: 'valid' }).cost).toBeCloseTo( + 0.25 * ENROW_CREDIT_USD + ) + expect(cost(enrowVerifyEmailTool, {}, { qualification: 'invalid' }).cost).toBeCloseTo( + 0.25 * ENROW_CREDIT_USD + ) + }) +}) + +describe('Enrow find_email postProcess polling', () => { + it('polls until 200 and resolves the result', async () => { + vi.useFakeTimers() + + const fetchMock = vi + .fn() + // First poll → 202 (still in progress) + .mockResolvedValueOnce(new Response(null, { status: 202 })) + // Second poll → 200 (complete) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + email: 'john@stripe.com', + qualification: 'valid', + fullname: 'John Doe', + company_name: 'Stripe', + company_domain: 'stripe.com', + linkedin_url: 'https://linkedin.com/in/johndoe', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ) + + vi.stubGlobal('fetch', fetchMock) + + const initial = { + success: true as const, + output: { + id: 'abc-123', + email: null, + qualification: null, + fullname: null, + company_name: null, + company_domain: null, + linkedin_url: null, + }, + } + + const promise = enrowFindEmailTool.postProcess!( + initial as never, + { apiKey: 'test-key', fullname: 'John Doe', company_domain: 'stripe.com' } as never, + vi.fn() + ) + + // Advance past two POLL_INTERVAL_MS intervals (3000ms each) + await vi.advanceTimersByTimeAsync(3000) + await vi.advanceTimersByTimeAsync(3000) + const result = await promise + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.enrow.io/email/find/single?id=abc-123', + expect.objectContaining({ headers: expect.objectContaining({ 'x-api-key': 'test-key' }) }) + ) + expect(result.success).toBe(true) + expect((result.output as Record).email).toBe('john@stripe.com') + expect((result.output as Record).qualification).toBe('valid') + }) +}) + +describe('Enrow verify_email postProcess polling', () => { + it('polls until 200 and resolves the verification result', async () => { + vi.useFakeTimers() + + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + email: 'john@stripe.com', + qualification: 'valid', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ) + + vi.stubGlobal('fetch', fetchMock) + + const initial = { + success: true as const, + output: { id: 'xyz-456', email: null, qualification: null }, + } + + const promise = enrowVerifyEmailTool.postProcess!( + initial as never, + { apiKey: 'test-key', email: 'john@stripe.com' } as never, + vi.fn() + ) + + await vi.advanceTimersByTimeAsync(3000) + const result = await promise + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.enrow.io/email/verify/single?id=xyz-456', + expect.objectContaining({ headers: expect.objectContaining({ 'x-api-key': 'test-key' }) }) + ) + expect(result.success).toBe(true) + expect((result.output as Record).qualification).toBe('valid') + }) +}) diff --git a/apps/sim/tools/enrow/find_email.ts b/apps/sim/tools/enrow/find_email.ts new file mode 100644 index 00000000000..8ab5c0c4d29 --- /dev/null +++ b/apps/sim/tools/enrow/find_email.ts @@ -0,0 +1,192 @@ +import { sleep } from '@sim/utils/helpers' +import { enrowHosting } from '@/tools/enrow/hosting' +import type { + EnrowFindEmailParams, + EnrowFindEmailResponse, + EnrowFindEmailResult, +} from '@/tools/enrow/types' +import { + ENROW_EMAIL_OUTPUT, + ENROW_ID_OUTPUT, + ENROW_QUALIFICATION_OUTPUT, +} from '@/tools/enrow/types' +import type { ToolConfig } from '@/tools/types' + +const POLL_INTERVAL_MS = 3000 +const MAX_POLL_TIME_MS = 120_000 + +/** Map a raw Enrow find-email result payload to the typed output shape. */ +function mapFindResult(data: Record): EnrowFindEmailResult { + return { + id: (data.id as string) ?? '', + email: (data.email as string) ?? null, + qualification: (data.qualification as string) ?? null, + fullname: (data.fullname as string) ?? null, + company_name: (data.company_name as string) ?? null, + company_domain: (data.company_domain as string) ?? null, + linkedin_url: (data.linkedin_url as string) ?? null, + } +} + +/** + * Enrow — Find Email (single, async). + * + * Submits a search via `POST https://api.enrow.io/email/find/single`, receives + * a job `id`, then polls `GET https://api.enrow.io/email/find/single?id=` + * until HTTP 200 (complete) or the polling window expires. HTTP 202 means the + * search is still in progress. + * + * Pricing: 1 credit per valid email found (charged only on success). + * Docs: https://enrow.readme.io/reference/find-single-email + */ +export const enrowFindEmailTool: ToolConfig = { + id: 'enrow_find_email', + name: 'Enrow Find Email', + description: + 'Find a verified B2B email address from a full name and company domain or name. Uses the Enrow async finder — submits a search and polls until the result is ready. Costs 1 credit per valid email found. (https://enrow.readme.io/reference/find-single-email)', + version: '1.0.0', + + hosting: enrowHosting((_params, output) => { + // 1 credit charged only when a valid email is returned + return output.qualification === 'valid' ? 1 : 0 + }), + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Enrow API key', + }, + fullname: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Full name of the person (e.g. "John Doe")', + }, + company_domain: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Company domain (e.g. "apple.com"). Preferred over company_name.', + }, + company_name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Company name (e.g. "Apple"). Used when domain is unavailable.', + }, + }, + + request: { + url: 'https://api.enrow.io/email/find/single', + method: 'POST', + headers: (params: EnrowFindEmailParams) => ({ + 'x-api-key': params.apiKey, + 'Content-Type': 'application/json', + }), + body: (params: EnrowFindEmailParams) => { + const body: Record = { fullname: params.fullname } + if (params.company_domain) body.company_domain = params.company_domain + if (params.company_name) body.company_name = params.company_name + return body + }, + }, + + transformResponse: async (response: Response): Promise => { + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Enrow API error: ${response.status} - ${errorText}`) + } + const json = await response.json() + const id = (json.id as string) ?? null + if (!id) { + throw new Error('Enrow find-email did not return a job id') + } + return { + success: true, + output: { + id, + email: null, + qualification: null, + fullname: null, + company_name: null, + company_domain: null, + linkedin_url: null, + }, + } + }, + + postProcess: async ( + result: EnrowFindEmailResponse, + params: EnrowFindEmailParams + ): Promise => { + if (!result.success) return result + + const jobId = result.output.id + if (!jobId) { + throw new Error('Enrow find-email did not return a job id to poll') + } + + let elapsed = 0 + while (elapsed < MAX_POLL_TIME_MS) { + await sleep(POLL_INTERVAL_MS) + elapsed += POLL_INTERVAL_MS + + const pollResponse = await fetch( + `https://api.enrow.io/email/find/single?id=${encodeURIComponent(jobId)}`, + { + headers: { + 'x-api-key': params.apiKey, + }, + } + ) + + if (pollResponse.status === 202) { + // Still in progress — keep polling + continue + } + + if (!pollResponse.ok) { + const errorText = await pollResponse.text() + throw new Error(`Enrow find-email poll error: ${pollResponse.status} - ${errorText}`) + } + + // HTTP 200 → complete + const json = await pollResponse.json() + const data = (json as Record) ?? {} + return { + success: true, + output: mapFindResult({ ...data, id: jobId }), + } + } + + throw new Error('Enrow find-email did not complete within the polling window') + }, + + outputs: { + id: ENROW_ID_OUTPUT, + email: ENROW_EMAIL_OUTPUT, + qualification: ENROW_QUALIFICATION_OUTPUT, + fullname: { + type: 'string', + description: 'Full name of the person searched', + optional: true, + }, + company_name: { + type: 'string', + description: 'Company name associated with the result', + optional: true, + }, + company_domain: { + type: 'string', + description: 'Company domain associated with the result', + optional: true, + }, + linkedin_url: { + type: 'string', + description: 'LinkedIn profile URL of the person', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/enrow/hosting.ts b/apps/sim/tools/enrow/hosting.ts new file mode 100644 index 00000000000..292905043e7 --- /dev/null +++ b/apps/sim/tools/enrow/hosting.ts @@ -0,0 +1,44 @@ +import type { ToolHostingConfig } from '@/tools/types' + +/** + * Env var prefix for Enrow hosted keys. Provide keys as `ENROW_API_KEY_COUNT` + * plus `ENROW_API_KEY_1..N`. + */ +export const ENROW_API_KEY_PREFIX = 'ENROW_API_KEY' + +/** + * Dollar cost of a single Enrow credit. + * + * Enrow's Starter plan is $24/month for 2,000 finder credits/month — $0.012 + * per credit. The email verifier costs 0.25 credits per verification and the + * email finder costs 1 credit per valid result. + * Source: https://enrow.io/pricing + */ +export const ENROW_CREDIT_USD = 0.012 + +/** + * Build an Enrow `hosting` config. `getCredits` returns the number of Enrow + * credits consumed by the call, derived from the tool's final output. + */ +export function enrowHosting

( + getCredits: (params: P, output: Record) => number +): ToolHostingConfig

{ + return { + envKeyPrefix: ENROW_API_KEY_PREFIX, + apiKeyParam: 'apiKey', + byokProviderId: 'enrow', + pricing: { + type: 'custom', + getCost: (params, output) => { + const credits = getCredits(params, output) + return { cost: credits * ENROW_CREDIT_USD, metadata: { credits } } + }, + }, + rateLimit: { + mode: 'per_request', + // Enrow rate limit is ~50 req/s; cap at 60 req/min to stay conservative + // and avoid bursting into the limit during polling. + requestsPerMinute: 60, + }, + } +} diff --git a/apps/sim/tools/enrow/index.ts b/apps/sim/tools/enrow/index.ts new file mode 100644 index 00000000000..37c44e05bdf --- /dev/null +++ b/apps/sim/tools/enrow/index.ts @@ -0,0 +1,6 @@ +export * from './types' + +import { enrowFindEmailTool } from '@/tools/enrow/find_email' +import { enrowVerifyEmailTool } from '@/tools/enrow/verify_email' + +export { enrowFindEmailTool, enrowVerifyEmailTool } diff --git a/apps/sim/tools/enrow/types.ts b/apps/sim/tools/enrow/types.ts new file mode 100644 index 00000000000..1638a9ad81b --- /dev/null +++ b/apps/sim/tools/enrow/types.ts @@ -0,0 +1,82 @@ +import type { OutputProperty, ToolResponse } from '@/tools/types' + +/** Common params shared by all Enrow tool operations. */ +export interface EnrowBaseParams { + apiKey: string +} + +// --------------------------------------------------------------------------- +// Email Finder — single +// --------------------------------------------------------------------------- + +export interface EnrowFindEmailParams extends EnrowBaseParams { + fullname: string + company_domain?: string + company_name?: string +} + +export interface EnrowFindEmailResult { + /** Job ID returned by the submit call; used to poll for the result. */ + id: string + email: string | null + /** Enrow quality qualifier: "valid" | "invalid" | null (if not yet finished). */ + qualification: string | null + fullname: string | null + company_name: string | null + company_domain: string | null + linkedin_url: string | null +} + +export interface EnrowFindEmailResponse extends ToolResponse { + output: EnrowFindEmailResult +} + +// --------------------------------------------------------------------------- +// Email Verifier — single +// --------------------------------------------------------------------------- + +export interface EnrowVerifyEmailParams extends EnrowBaseParams { + email: string +} + +export interface EnrowVerifyEmailResult { + /** Job ID returned by the submit call; used to poll for the result. */ + id: string + email: string | null + /** Enrow quality qualifier: "valid" | "invalid" | null (if not yet finished). */ + qualification: string | null +} + +export interface EnrowVerifyEmailResponse extends ToolResponse { + output: EnrowVerifyEmailResult +} + +// --------------------------------------------------------------------------- +// Union response type (used in BlockConfig generic) +// --------------------------------------------------------------------------- + +export type EnrowResponse = EnrowFindEmailResponse | EnrowVerifyEmailResponse + +// --------------------------------------------------------------------------- +// Shared output property constants +// --------------------------------------------------------------------------- + +/** Reusable output-property definition for the Enrow job ID. */ +export const ENROW_ID_OUTPUT: OutputProperty = { + type: 'string', + description: 'Enrow job identifier used for polling', +} + +/** Reusable output-property definition for the returned email address. */ +export const ENROW_EMAIL_OUTPUT: OutputProperty = { + type: 'string', + description: 'Email address found or verified', + optional: true, +} + +/** Reusable output-property definition for the qualification field. */ +export const ENROW_QUALIFICATION_OUTPUT: OutputProperty = { + type: 'string', + description: 'Enrow quality result: "valid" or "invalid"', + optional: true, +} diff --git a/apps/sim/tools/enrow/verify_email.ts b/apps/sim/tools/enrow/verify_email.ts new file mode 100644 index 00000000000..c59e314efa9 --- /dev/null +++ b/apps/sim/tools/enrow/verify_email.ts @@ -0,0 +1,149 @@ +import { sleep } from '@sim/utils/helpers' +import { enrowHosting } from '@/tools/enrow/hosting' +import type { + EnrowVerifyEmailParams, + EnrowVerifyEmailResponse, + EnrowVerifyEmailResult, +} from '@/tools/enrow/types' +import { + ENROW_EMAIL_OUTPUT, + ENROW_ID_OUTPUT, + ENROW_QUALIFICATION_OUTPUT, +} from '@/tools/enrow/types' +import type { ToolConfig } from '@/tools/types' + +const POLL_INTERVAL_MS = 3000 +const MAX_POLL_TIME_MS = 120_000 + +/** Map a raw Enrow verify-email result payload to the typed output shape. */ +function mapVerifyResult(data: Record, jobId: string): EnrowVerifyEmailResult { + return { + id: jobId, + email: (data.email as string) ?? null, + qualification: (data.qualification as string) ?? null, + } +} + +/** + * Enrow — Verify Email (single, async). + * + * Submits a verification via `POST https://api.enrow.io/email/verify/single`, + * receives a job `id`, then polls + * `GET https://api.enrow.io/email/verify/single?id=` until HTTP 200 + * (complete) or the polling window expires. HTTP 202 means still in progress. + * + * Pricing: 0.25 credits per verification (charged per call). + * Docs: https://enrow.readme.io/reference/verify-single-email + */ +export const enrowVerifyEmailTool: ToolConfig = { + id: 'enrow_verify_email', + name: 'Enrow Verify Email', + description: + 'Verify the deliverability of an email address using the Enrow async verifier. Submits a verification request and polls until the result is ready. Costs 0.25 credits per verification. (https://enrow.readme.io/reference/verify-single-email)', + version: '1.0.0', + + hosting: enrowHosting((_params, _output) => { + // 0.25 credits charged per verification call regardless of result + return 0.25 + }), + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Enrow API key', + }, + email: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Email address to verify (e.g. "john@example.com")', + }, + }, + + request: { + url: 'https://api.enrow.io/email/verify/single', + method: 'POST', + headers: (params: EnrowVerifyEmailParams) => ({ + 'x-api-key': params.apiKey, + 'Content-Type': 'application/json', + }), + body: (params: EnrowVerifyEmailParams) => ({ + email: params.email, + }), + }, + + transformResponse: async (response: Response): Promise => { + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Enrow API error: ${response.status} - ${errorText}`) + } + const json = await response.json() + const id = (json.id as string) ?? null + if (!id) { + throw new Error('Enrow verify-email did not return a job id') + } + return { + success: true, + output: { + id, + email: null, + qualification: null, + }, + } + }, + + postProcess: async ( + result: EnrowVerifyEmailResponse, + params: EnrowVerifyEmailParams + ): Promise => { + if (!result.success) return result + + const jobId = result.output.id + if (!jobId) { + throw new Error('Enrow verify-email did not return a job id to poll') + } + + let elapsed = 0 + while (elapsed < MAX_POLL_TIME_MS) { + await sleep(POLL_INTERVAL_MS) + elapsed += POLL_INTERVAL_MS + + const pollResponse = await fetch( + `https://api.enrow.io/email/verify/single?id=${encodeURIComponent(jobId)}`, + { + headers: { + 'x-api-key': params.apiKey, + }, + } + ) + + if (pollResponse.status === 202) { + // Still in progress — keep polling + continue + } + + if (!pollResponse.ok) { + const errorText = await pollResponse.text() + throw new Error(`Enrow verify-email poll error: ${pollResponse.status} - ${errorText}`) + } + + // HTTP 200 → complete + const json = await pollResponse.json() + const data = (json as Record) ?? {} + return { + success: true, + output: mapVerifyResult(data, jobId), + } + } + + throw new Error('Enrow verify-email did not complete within the polling window') + }, + + outputs: { + id: ENROW_ID_OUTPUT, + email: ENROW_EMAIL_OUTPUT, + qualification: ENROW_QUALIFICATION_OUTPUT, + }, +} diff --git a/apps/sim/tools/icypeas-hosting.test.ts b/apps/sim/tools/icypeas-hosting.test.ts new file mode 100644 index 00000000000..671b358e8fd --- /dev/null +++ b/apps/sim/tools/icypeas-hosting.test.ts @@ -0,0 +1,218 @@ +/** + * @vitest-environment node + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { icypeasFindEmailTool } from '@/tools/icypeas/find_email' +import { ICYPEAS_CREDIT_USD } from '@/tools/icypeas/hosting' +import { icypeasVerifyEmailTool } from '@/tools/icypeas/verify_email' +import type { ToolConfig } from '@/tools/types' + +afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +function cost(tool: ToolConfig, params: any, output: Record) { + const pricing = tool.hosting?.pricing + if (!pricing || pricing.type !== 'custom') throw new Error('Expected custom pricing') + const result = pricing.getCost(params, output) + return typeof result === 'number' ? { cost: result } : result +} + +describe('Icypeas hosted key config', () => { + it('declares the correct env prefix and BYOK provider ID', () => { + expect(icypeasFindEmailTool.hosting?.envKeyPrefix).toBe('ICYPEAS_API_KEY') + expect(icypeasFindEmailTool.hosting?.byokProviderId).toBe('icypeas') + expect(icypeasVerifyEmailTool.hosting?.envKeyPrefix).toBe('ICYPEAS_API_KEY') + expect(icypeasVerifyEmailTool.hosting?.byokProviderId).toBe('icypeas') + }) +}) + +describe('Icypeas find-email pricing', () => { + it('charges 1 credit when status is FOUND', () => { + expect(cost(icypeasFindEmailTool, {}, { status: 'FOUND', email: 'a@b.com' }).cost).toBeCloseTo( + ICYPEAS_CREDIT_USD + ) + }) + + it('charges 1 credit when status is DEBITED', () => { + expect( + cost(icypeasFindEmailTool, {}, { status: 'DEBITED', email: 'a@b.com' }).cost + ).toBeCloseTo(ICYPEAS_CREDIT_USD) + }) + + it('charges 0 credits when the email was not found', () => { + expect(cost(icypeasFindEmailTool, {}, { status: 'NOT_FOUND', email: null }).cost).toBe(0) + expect(cost(icypeasFindEmailTool, {}, { status: 'DEBITED_NOT_FOUND', email: null }).cost).toBe( + 0 + ) + expect(cost(icypeasFindEmailTool, {}, { status: 'BAD_INPUT', email: null }).cost).toBe(0) + }) +}) + +describe('Icypeas verify-email pricing', () => { + it('charges 0.1 credits for FOUND status', () => { + expect( + cost(icypeasVerifyEmailTool, {}, { status: 'FOUND', email: 'a@b.com' }).cost + ).toBeCloseTo(0.1 * ICYPEAS_CREDIT_USD) + }) + + it('charges 0.1 credits for DEBITED status', () => { + expect( + cost(icypeasVerifyEmailTool, {}, { status: 'DEBITED', email: 'a@b.com' }).cost + ).toBeCloseTo(0.1 * ICYPEAS_CREDIT_USD) + }) + + it('charges 0.1 credits for DEBITED_NOT_FOUND (credits were consumed)', () => { + expect( + cost(icypeasVerifyEmailTool, {}, { status: 'DEBITED_NOT_FOUND', email: 'a@b.com' }).cost + ).toBeCloseTo(0.1 * ICYPEAS_CREDIT_USD) + }) + + it('charges 0 credits for non-billable statuses', () => { + expect(cost(icypeasVerifyEmailTool, {}, { status: 'NOT_FOUND', email: 'a@b.com' }).cost).toBe(0) + expect(cost(icypeasVerifyEmailTool, {}, { status: 'BAD_INPUT', email: 'a@b.com' }).cost).toBe(0) + expect( + cost(icypeasVerifyEmailTool, {}, { status: 'INSUFFICIENT_FUNDS', email: 'a@b.com' }).cost + ).toBe(0) + expect(cost(icypeasVerifyEmailTool, {}, { status: 'ABORTED', email: 'a@b.com' }).cost).toBe(0) + }) + + it('throws when status is missing', () => { + expect(() => cost(icypeasVerifyEmailTool, {}, { email: 'a@b.com' })).toThrow(/status/) + }) +}) + +describe('Icypeas find-email postProcess poll', () => { + it('polls the results endpoint until terminal status and returns the email', async () => { + vi.useFakeTimers() + + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + success: true, + item: { + _id: 'abc123', + status: 'FOUND', + results: { + firstname: 'John', + lastname: 'Doe', + emails: [{ email: 'john@stripe.com', certainty: 'ultra_sure' }], + }, + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ) + vi.stubGlobal('fetch', fetchMock) + + const initial = { + success: true as const, + output: { + searchId: 'abc123', + status: 'NONE', + email: null, + firstname: null, + lastname: null, + item: { _id: 'abc123', status: 'NONE' }, + }, + } + + const promise = icypeasFindEmailTool.postProcess!( + initial as any, + { apiKey: 'test-key', domainOrCompany: 'stripe.com' } as any, + vi.fn() + ) + await vi.advanceTimersByTimeAsync(3000) + const result = await promise + + expect(fetchMock).toHaveBeenCalledWith( + 'https://app.icypeas.com/api/bulk-single-searchs/read', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ Authorization: 'test-key' }), + }) + ) + expect(result.success).toBe(true) + expect((result.output as any).email).toBe('john@stripe.com') + expect((result.output as any).status).toBe('FOUND') + }) + + it('returns success=false for NOT_FOUND terminal status', async () => { + vi.useFakeTimers() + + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + success: true, + item: { _id: 'abc456', status: 'NOT_FOUND', email: null }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ) + vi.stubGlobal('fetch', fetchMock) + + const initial = { + success: true as const, + output: { + searchId: 'abc456', + status: 'SCHEDULED', + email: null, + firstname: null, + lastname: null, + item: { _id: 'abc456', status: 'SCHEDULED' }, + }, + } + + const promise = icypeasFindEmailTool.postProcess!( + initial as any, + { apiKey: 'test-key', domainOrCompany: 'stripe.com' } as any, + vi.fn() + ) + await vi.advanceTimersByTimeAsync(3000) + const result = await promise + + expect(result.success).toBe(false) + expect((result.output as any).status).toBe('NOT_FOUND') + }) +}) + +describe('Icypeas verify-email postProcess poll', () => { + it('polls the results endpoint until terminal status and returns valid=true for FOUND', async () => { + vi.useFakeTimers() + + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + success: true, + item: { _id: 'xyz789', status: 'DEBITED', email: 'jane@example.com' }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ) + vi.stubGlobal('fetch', fetchMock) + + const initial = { + success: true as const, + output: { + searchId: 'xyz789', + status: 'IN_PROGRESS', + email: 'jane@example.com', + valid: null, + item: { _id: 'xyz789', status: 'IN_PROGRESS' }, + }, + } + + const promise = icypeasVerifyEmailTool.postProcess!( + initial as any, + { apiKey: 'test-key', email: 'jane@example.com' } as any, + vi.fn() + ) + await vi.advanceTimersByTimeAsync(3000) + const result = await promise + + expect(result.success).toBe(true) + expect((result.output as any).valid).toBe(true) + expect((result.output as any).status).toBe('DEBITED') + }) +}) diff --git a/apps/sim/tools/icypeas/find_email.ts b/apps/sim/tools/icypeas/find_email.ts new file mode 100644 index 00000000000..97495b24adc --- /dev/null +++ b/apps/sim/tools/icypeas/find_email.ts @@ -0,0 +1,191 @@ +import { sleep } from '@sim/utils/helpers' +import { icypeasHosting } from '@/tools/icypeas/hosting' +import type { + IcypeasFindEmailOutput, + IcypeasFindEmailParams, + IcypeasFindEmailResponse, +} from '@/tools/icypeas/types' +import { + ICYPEAS_EMAIL_OUTPUT, + ICYPEAS_ITEM_OUTPUT, + ICYPEAS_SEARCH_ID_OUTPUT, + ICYPEAS_STATUS_OUTPUT, +} from '@/tools/icypeas/types' +import type { ToolConfig } from '@/tools/types' + +/** Icypeas statuses that indicate the search has finished (success or failure). */ +const TERMINAL_STATUSES = new Set([ + 'FOUND', + 'DEBITED', + 'NOT_FOUND', + 'DEBITED_NOT_FOUND', + 'BAD_INPUT', + 'INSUFFICIENT_FUNDS', + 'ABORTED', +]) + +/** Icypeas statuses that indicate a result was actually found. */ +const FOUND_STATUSES = new Set(['FOUND', 'DEBITED']) + +const POLL_INTERVAL_MS = 3000 +const MAX_POLL_TIME_MS = 120000 + +/** Map a raw Icypeas item object to the tool output shape. */ +function mapItem(item: Record): IcypeasFindEmailOutput { + const status = (item.status as string | undefined) ?? null + // Results are nested under item.results; emails are in item.results.emails[0].email + const results = (item.results as Record | undefined) ?? {} + const emails = Array.isArray(results.emails) ? (results.emails as Record[]) : [] + const email = (emails[0]?.email as string | undefined) ?? null + const firstname = (results.firstname as string | undefined) ?? null + const lastname = (results.lastname as string | undefined) ?? null + return { + searchId: (item._id as string | undefined) ?? null, + status, + email, + firstname, + lastname, + item, + } +} + +export const icypeasFindEmailTool: ToolConfig = { + id: 'icypeas_find_email', + name: 'Icypeas Find Email', + description: + 'Find a professional email address from a first name, last name, and company domain or name. Submits the search and polls until a result is available. Costs 1 credit per found email (https://www.icypeas.com/pricing).', + version: '1.0.0', + + hosting: icypeasHosting((_params, output) => { + const status = output.status as string | undefined + // 1 credit charged only when a result is found (FOUND / DEBITED status). + return status && FOUND_STATUSES.has(status) ? 1 : 0 + }), + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Icypeas API key', + }, + firstname: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: "Target person's first name", + }, + lastname: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: "Target person's last name", + }, + domainOrCompany: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Target company domain (e.g. stripe.com) or company name (e.g. Stripe)', + }, + }, + + request: { + url: 'https://app.icypeas.com/api/email-search', + method: 'POST', + headers: (params: IcypeasFindEmailParams) => ({ + Authorization: params.apiKey, + 'Content-Type': 'application/json', + }), + body: (params: IcypeasFindEmailParams) => { + const body: Record = { + domainOrCompany: params.domainOrCompany, + } + if (params.firstname) body.firstname = params.firstname + if (params.lastname) body.lastname = params.lastname + return body + }, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Icypeas API error: ${response.status} - ${errorText}`) + } + const json = (await response.json()) as Record + // Submit response: { success: true, item: { _id: '...', status: 'NONE', ... } } + const item = (json.item as Record | undefined) ?? {} + const searchId = (item._id as string | undefined) ?? null + if (!searchId) { + throw new Error('Icypeas email-search did not return an item _id') + } + return { + success: true, + output: mapItem(item), + } + }, + + postProcess: async (result, params) => { + if (!result.success) return result + + const searchId = result.output.searchId + if (!searchId) { + throw new Error('Icypeas find-email result is missing a searchId') + } + + // If already terminal (unlikely on submit but defensive), return immediately. + if (result.output.status && TERMINAL_STATUSES.has(result.output.status)) { + return result + } + + let elapsed = 0 + while (elapsed < MAX_POLL_TIME_MS) { + await sleep(POLL_INTERVAL_MS) + elapsed += POLL_INTERVAL_MS + + const pollResponse = await fetch('https://app.icypeas.com/api/bulk-single-searchs/read', { + method: 'POST', + headers: { + Authorization: params.apiKey, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ id: searchId }), + }) + + if (!pollResponse.ok) { + const errorText = await pollResponse.text() + throw new Error(`Icypeas poll error: ${pollResponse.status} - ${errorText}`) + } + + const json = (await pollResponse.json()) as Record + // Poll response: { success: true, item: { _id: '...', status: '...', results: { emails: [...], firstname, lastname } } } + const item = (json.item as Record | undefined) ?? {} + const status = (item.status as string | undefined) ?? null + + if (status && TERMINAL_STATUSES.has(status)) { + return { + success: FOUND_STATUSES.has(status), + output: mapItem(item), + } + } + } + + throw new Error('Icypeas email-search did not complete within the polling window') + }, + + outputs: { + searchId: ICYPEAS_SEARCH_ID_OUTPUT, + status: ICYPEAS_STATUS_OUTPUT, + email: ICYPEAS_EMAIL_OUTPUT, + firstname: { + type: 'string', + description: "Found person's first name", + optional: true, + }, + lastname: { + type: 'string', + description: "Found person's last name", + optional: true, + }, + item: ICYPEAS_ITEM_OUTPUT, + }, +} diff --git a/apps/sim/tools/icypeas/hosting.ts b/apps/sim/tools/icypeas/hosting.ts new file mode 100644 index 00000000000..98e6ff9091a --- /dev/null +++ b/apps/sim/tools/icypeas/hosting.ts @@ -0,0 +1,50 @@ +import type { ToolHostingConfig } from '@/tools/types' + +/** + * Env var prefix for Icypeas hosted keys. Provide keys as `ICYPEAS_API_KEY_COUNT` + * plus `ICYPEAS_API_KEY_1..N`. + */ +export const ICYPEAS_API_KEY_PREFIX = 'ICYPEAS_API_KEY' + +/** + * Dollar cost of a single Icypeas credit. + * + * Icypeas meters usage in credits at approximately $0.019/credit on the entry + * Basic plan (1,000 credits for $19/month). Higher-tier plans reduce cost to as + * low as $0.00499/credit. We use the Basic-plan rate as a conservative baseline. + * + * Credit costs per operation (source: https://www.icypeas.com/pricing): + * - Email Finder: 1 credit per found email + * - Email Verifier: 0.1 credit per verification + * - Domain Scan: 1 credit per domain + * - Profile Scraper: 1.5 credits per profile + * - Reverse Email Lookup: 10 credits per found profile + * + * Credits are charged only when a result is returned (FOUND / DEBITED status). + */ +export const ICYPEAS_CREDIT_USD = 0.019 + +/** + * Build an Icypeas `hosting` config. `getCredits` returns the number of Icypeas + * credits the call consumed, derived from the tool's final output. + */ +export function icypeasHosting

( + getCredits: (params: P, output: Record) => number +): ToolHostingConfig

{ + return { + envKeyPrefix: ICYPEAS_API_KEY_PREFIX, + apiKeyParam: 'apiKey', + byokProviderId: 'icypeas', + pricing: { + type: 'custom', + getCost: (params, output) => { + const credits = getCredits(params, output) + return { cost: credits * ICYPEAS_CREDIT_USD, metadata: { credits } } + }, + }, + rateLimit: { + mode: 'per_request', + requestsPerMinute: 60, + }, + } +} diff --git a/apps/sim/tools/icypeas/index.ts b/apps/sim/tools/icypeas/index.ts new file mode 100644 index 00000000000..69d83baeb13 --- /dev/null +++ b/apps/sim/tools/icypeas/index.ts @@ -0,0 +1,6 @@ +export * from './types' + +import { icypeasFindEmailTool } from '@/tools/icypeas/find_email' +import { icypeasVerifyEmailTool } from '@/tools/icypeas/verify_email' + +export { icypeasFindEmailTool, icypeasVerifyEmailTool } diff --git a/apps/sim/tools/icypeas/types.ts b/apps/sim/tools/icypeas/types.ts new file mode 100644 index 00000000000..44f0fafe628 --- /dev/null +++ b/apps/sim/tools/icypeas/types.ts @@ -0,0 +1,89 @@ +import type { OutputProperty, ToolResponse } from '@/tools/types' + +/** Base params shared by every Icypeas operation. */ +export interface IcypeasBaseParams { + apiKey: string +} + +// --------------------------------------------------------------------------- +// Email Finder (single email discovery) +// --------------------------------------------------------------------------- + +export interface IcypeasFindEmailParams extends IcypeasBaseParams { + firstname?: string + lastname?: string + domainOrCompany: string +} + +export interface IcypeasFindEmailOutput { + /** Icypeas internal search ID used to poll the result. */ + searchId: string | null + status: string | null + email: string | null + firstname: string | null + lastname: string | null + /** Raw item object from the results endpoint. */ + item: Record | null +} + +export interface IcypeasFindEmailResponse extends ToolResponse { + output: IcypeasFindEmailOutput +} + +// --------------------------------------------------------------------------- +// Email Verification +// --------------------------------------------------------------------------- + +export interface IcypeasVerifyEmailParams extends IcypeasBaseParams { + email: string +} + +export interface IcypeasVerifyEmailOutput { + /** Icypeas internal search ID used to poll the result. */ + searchId: string | null + status: string | null + email: string | null + /** Whether the email is valid/found. Derived from terminal status. */ + valid: boolean | null + /** Raw item object from the results endpoint. */ + item: Record | null +} + +export interface IcypeasVerifyEmailResponse extends ToolResponse { + output: IcypeasVerifyEmailOutput +} + +// --------------------------------------------------------------------------- +// Union response type used by the block +// --------------------------------------------------------------------------- + +export type IcypeasResponse = IcypeasFindEmailResponse | IcypeasVerifyEmailResponse + +// --------------------------------------------------------------------------- +// Shared output property constants +// --------------------------------------------------------------------------- + +export const ICYPEAS_SEARCH_ID_OUTPUT: OutputProperty = { + type: 'string', + description: 'Icypeas internal search ID', + optional: true, +} + +export const ICYPEAS_STATUS_OUTPUT: OutputProperty = { + type: 'string', + description: + 'Terminal search status: FOUND | DEBITED | NOT_FOUND | DEBITED_NOT_FOUND | BAD_INPUT | INSUFFICIENT_FUNDS | ABORTED', + optional: true, +} + +export const ICYPEAS_EMAIL_OUTPUT: OutputProperty = { + type: 'string', + description: 'Email address found or verified', + optional: true, +} + +export const ICYPEAS_ITEM_OUTPUT: OutputProperty = { + type: 'json', + description: 'Full raw item object returned by the Icypeas results endpoint', + optional: true, +} diff --git a/apps/sim/tools/icypeas/verify_email.ts b/apps/sim/tools/icypeas/verify_email.ts new file mode 100644 index 00000000000..adff8c2be4e --- /dev/null +++ b/apps/sim/tools/icypeas/verify_email.ts @@ -0,0 +1,179 @@ +import { sleep } from '@sim/utils/helpers' +import { icypeasHosting } from '@/tools/icypeas/hosting' +import type { + IcypeasVerifyEmailOutput, + IcypeasVerifyEmailParams, + IcypeasVerifyEmailResponse, +} from '@/tools/icypeas/types' +import { + ICYPEAS_EMAIL_OUTPUT, + ICYPEAS_ITEM_OUTPUT, + ICYPEAS_SEARCH_ID_OUTPUT, + ICYPEAS_STATUS_OUTPUT, +} from '@/tools/icypeas/types' +import type { ToolConfig } from '@/tools/types' + +/** Icypeas statuses that indicate the search has finished (success or failure). */ +const TERMINAL_STATUSES = new Set([ + 'FOUND', + 'DEBITED', + 'NOT_FOUND', + 'DEBITED_NOT_FOUND', + 'BAD_INPUT', + 'INSUFFICIENT_FUNDS', + 'ABORTED', +]) + +/** Icypeas statuses that indicate the email address is valid/deliverable. */ +const VALID_STATUSES = new Set(['FOUND', 'DEBITED']) + +const POLL_INTERVAL_MS = 3000 +const MAX_POLL_TIME_MS = 120000 + +/** Map a raw Icypeas item object to the verify-email output shape. */ +function mapItem(item: Record): IcypeasVerifyEmailOutput { + const status = (item.status as string | undefined) ?? null + // Results are nested under item.results; emails are in item.results.emails[0].email + const results = (item.results as Record | undefined) ?? {} + const emails = Array.isArray(results.emails) ? (results.emails as Record[]) : [] + const email = (emails[0]?.email as string | undefined) ?? null + const valid = status !== null ? VALID_STATUSES.has(status) : null + return { + searchId: (item._id as string | undefined) ?? null, + status, + email, + valid, + item, + } +} + +export const icypeasVerifyEmailTool: ToolConfig< + IcypeasVerifyEmailParams, + IcypeasVerifyEmailResponse +> = { + id: 'icypeas_verify_email', + name: 'Icypeas Verify Email', + description: + 'Verify whether an email address is valid and deliverable. Submits the verification and polls until a result is available. Costs 0.1 credit per verification (https://www.icypeas.com/pricing).', + version: '1.0.0', + + hosting: icypeasHosting((_params, output) => { + // 0.1 credit per verification attempt that was processed (FOUND, DEBITED, + // or DEBITED_NOT_FOUND — any status containing "DEBITED" means credits were + // consumed). BAD_INPUT / INSUFFICIENT_FUNDS / ABORTED / NOT_FOUND indicate + // the search was not processed or charged. + const status = output.status as string | undefined + if (!status) { + throw new Error('Icypeas verify-email: cannot determine cost — status is missing') + } + // Billable when the status name contains DEBITED (i.e. DEBITED or DEBITED_NOT_FOUND). + const billable = status.includes('DEBITED') + // 0.1 credit; express as a fractional number so ICYPEAS_CREDIT_USD math works. + return billable ? 0.1 : 0 + }), + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Icypeas API key', + }, + email: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Email address to verify (e.g. john@stripe.com)', + }, + }, + + request: { + url: 'https://app.icypeas.com/api/email-verification', + method: 'POST', + headers: (params: IcypeasVerifyEmailParams) => ({ + Authorization: params.apiKey, + 'Content-Type': 'application/json', + }), + body: (params: IcypeasVerifyEmailParams) => ({ + email: params.email, + }), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Icypeas API error: ${response.status} - ${errorText}`) + } + const json = (await response.json()) as Record + // Submit response: { success: true, item: { _id: '...', status: 'NONE', ... } } + const item = (json.item as Record | undefined) ?? {} + const searchId = (item._id as string | undefined) ?? null + if (!searchId) { + throw new Error('Icypeas email-verification did not return an item _id') + } + return { + success: true, + output: mapItem(item), + } + }, + + postProcess: async (result, params) => { + if (!result.success) return result + + const searchId = result.output.searchId + if (!searchId) { + throw new Error('Icypeas verify-email result is missing a searchId') + } + + // If already terminal, return immediately. + if (result.output.status && TERMINAL_STATUSES.has(result.output.status)) { + return result + } + + let elapsed = 0 + while (elapsed < MAX_POLL_TIME_MS) { + await sleep(POLL_INTERVAL_MS) + elapsed += POLL_INTERVAL_MS + + const pollResponse = await fetch('https://app.icypeas.com/api/bulk-single-searchs/read', { + method: 'POST', + headers: { + Authorization: params.apiKey, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ id: searchId }), + }) + + if (!pollResponse.ok) { + const errorText = await pollResponse.text() + throw new Error(`Icypeas poll error: ${pollResponse.status} - ${errorText}`) + } + + const json = (await pollResponse.json()) as Record + // Poll response: { success: true, item: { _id: '...', status: '...', results: { emails: [...] } } } + const item = (json.item as Record | undefined) ?? {} + const status = (item.status as string | undefined) ?? null + + if (status && TERMINAL_STATUSES.has(status)) { + return { + success: VALID_STATUSES.has(status), + output: mapItem(item), + } + } + } + + throw new Error('Icypeas email-verification did not complete within the polling window') + }, + + outputs: { + searchId: ICYPEAS_SEARCH_ID_OUTPUT, + status: ICYPEAS_STATUS_OUTPUT, + email: ICYPEAS_EMAIL_OUTPUT, + valid: { + type: 'boolean', + description: 'Whether the email is valid/deliverable (true for FOUND/DEBITED status)', + optional: true, + }, + item: ICYPEAS_ITEM_OUTPUT, + }, +} diff --git a/apps/sim/tools/leadmagic-hosting.test.ts b/apps/sim/tools/leadmagic-hosting.test.ts new file mode 100644 index 00000000000..5bbd5f8dffb --- /dev/null +++ b/apps/sim/tools/leadmagic-hosting.test.ts @@ -0,0 +1,129 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { companySearchTool } from '@/tools/leadmagic/company_search' +import { emailToProfileTool } from '@/tools/leadmagic/email_to_profile' +import { findEmailTool } from '@/tools/leadmagic/find_email' +import { findMobileTool } from '@/tools/leadmagic/find_mobile' +import { getCreditsTool } from '@/tools/leadmagic/get_credits' +import { LEADMAGIC_CREDIT_USD } from '@/tools/leadmagic/hosting' +import { profileSearchTool } from '@/tools/leadmagic/profile_search' +import { profileToEmailTool } from '@/tools/leadmagic/profile_to_email' +import { roleFinderTool } from '@/tools/leadmagic/role_finder' +import { validateEmailTool } from '@/tools/leadmagic/validate_email' +import type { ToolConfig } from '@/tools/types' + +function cost(tool: ToolConfig, params: any, output: Record) { + const pricing = tool.hosting?.pricing + if (!pricing || pricing.type !== 'custom') throw new Error('Expected custom pricing') + const result = pricing.getCost(params, output) + return typeof result === 'number' ? { cost: result } : result +} + +describe('LeadMagic hosted key config', () => { + it('declares the correct env prefix and BYOK provider for all credit-consuming tools', () => { + const tools = [ + validateEmailTool, + findEmailTool, + findMobileTool, + profileSearchTool, + profileToEmailTool, + emailToProfileTool, + companySearchTool, + roleFinderTool, + ] + for (const tool of tools) { + expect(tool.hosting?.envKeyPrefix).toBe('LEADMAGIC_API_KEY') + expect(tool.hosting?.byokProviderId).toBe('leadmagic') + } + }) + + it('get_credits has no hosting config (free endpoint)', () => { + expect(getCreditsTool.hosting).toBeUndefined() + }) +}) + +describe('LeadMagic hosted key pricing', () => { + it('validate_email: uses API-reported credits_consumed', () => { + expect(cost(validateEmailTool, {}, { credits_consumed: 0.25 }).cost).toBeCloseTo( + 0.25 * LEADMAGIC_CREDIT_USD + ) + expect(cost(validateEmailTool, {}, { credits_consumed: 0 }).cost).toBe(0) + }) + + it('find_email: 1 credit when email found, 0 otherwise', () => { + expect(cost(findEmailTool, {}, { credits_consumed: 1 }).cost).toBeCloseTo(LEADMAGIC_CREDIT_USD) + expect(cost(findEmailTool, {}, { credits_consumed: 0, email: null }).cost).toBe(0) + // fallback path when credits_consumed missing + expect(cost(findEmailTool, {}, { email: 'a@b.com' }).cost).toBeCloseTo(LEADMAGIC_CREDIT_USD) + expect(cost(findEmailTool, {}, { email: null }).cost).toBe(0) + }) + + it('find_mobile: 5 credits when mobile found, 0 otherwise', () => { + expect(cost(findMobileTool, {}, { credits_consumed: 5 }).cost).toBeCloseTo( + 5 * LEADMAGIC_CREDIT_USD + ) + expect(cost(findMobileTool, {}, { credits_consumed: 0 }).cost).toBe(0) + // fallback path + expect(cost(findMobileTool, {}, { mobile_number: '+15551234567' }).cost).toBeCloseTo( + 5 * LEADMAGIC_CREDIT_USD + ) + expect(cost(findMobileTool, {}, { mobile_number: null }).cost).toBe(0) + }) + + it('profile_search: 1 credit when profile found, 0 otherwise', () => { + expect(cost(profileSearchTool, {}, { credits_consumed: 1 }).cost).toBeCloseTo( + LEADMAGIC_CREDIT_USD + ) + expect(cost(profileSearchTool, {}, { credits_consumed: 0 }).cost).toBe(0) + }) + + it('profile_to_email: 5 credits when email found, 0 otherwise', () => { + expect(cost(profileToEmailTool, {}, { credits_consumed: 5 }).cost).toBeCloseTo( + 5 * LEADMAGIC_CREDIT_USD + ) + expect(cost(profileToEmailTool, {}, { credits_consumed: 0 }).cost).toBe(0) + // fallback path + expect(cost(profileToEmailTool, {}, { email: 'a@b.com' }).cost).toBeCloseTo( + 5 * LEADMAGIC_CREDIT_USD + ) + expect(cost(profileToEmailTool, {}, { email: null }).cost).toBe(0) + }) + + it('email_to_profile: 10 credits when profile found, 0 otherwise', () => { + expect(cost(emailToProfileTool, {}, { credits_consumed: 10 }).cost).toBeCloseTo( + 10 * LEADMAGIC_CREDIT_USD + ) + expect(cost(emailToProfileTool, {}, { credits_consumed: 0 }).cost).toBe(0) + // fallback path + expect( + cost(emailToProfileTool, {}, { profile_url: 'https://linkedin.com/in/johndoe' }).cost + ).toBeCloseTo(10 * LEADMAGIC_CREDIT_USD) + expect(cost(emailToProfileTool, {}, { profile_url: null }).cost).toBe(0) + }) + + it('company_search: 1 credit when company found, 0 otherwise', () => { + expect(cost(companySearchTool, {}, { credits_consumed: 1 }).cost).toBeCloseTo( + LEADMAGIC_CREDIT_USD + ) + expect(cost(companySearchTool, {}, { credits_consumed: 0 }).cost).toBe(0) + // fallback path + expect(cost(companySearchTool, {}, { companyName: 'Stripe' }).cost).toBeCloseTo( + LEADMAGIC_CREDIT_USD + ) + expect(cost(companySearchTool, {}, { companyName: null }).cost).toBe(0) + }) + + it('role_finder: 2 credits when person found, 0 otherwise', () => { + expect(cost(roleFinderTool, {}, { credits_consumed: 2 }).cost).toBeCloseTo( + 2 * LEADMAGIC_CREDIT_USD + ) + expect(cost(roleFinderTool, {}, { credits_consumed: 0 }).cost).toBe(0) + // fallback path + expect(cost(roleFinderTool, {}, { full_name: 'John Doe' }).cost).toBeCloseTo( + 2 * LEADMAGIC_CREDIT_USD + ) + expect(cost(roleFinderTool, {}, { full_name: null }).cost).toBe(0) + }) +}) diff --git a/apps/sim/tools/leadmagic/company_search.ts b/apps/sim/tools/leadmagic/company_search.ts new file mode 100644 index 00000000000..e16e677d0b6 --- /dev/null +++ b/apps/sim/tools/leadmagic/company_search.ts @@ -0,0 +1,134 @@ +import { leadmagicHosting } from '@/tools/leadmagic/hosting' +import type { + LeadMagicCompanySearchParams, + LeadMagicCompanySearchResponse, +} from '@/tools/leadmagic/types' +import type { ToolConfig } from '@/tools/types' + +export const companySearchTool: ToolConfig< + LeadMagicCompanySearchParams, + LeadMagicCompanySearchResponse +> = { + id: 'leadmagic_company_search', + name: 'LeadMagic Company Search', + description: + 'Enrich company data including firmographics, headcount, funding, and social profiles by domain, LinkedIn URL, or name. Charges 1 credit when a company is found; free when no result.', + version: '1.0.0', + + hosting: leadmagicHosting((_params, output) => { + // 1 credit when company found, 0 otherwise. + // Source: https://leadmagic.io/docs/v1/reference/company-search + const consumed = output.credits_consumed + return typeof consumed === 'number' ? consumed : output.companyName ? 1 : 0 + }), + + params: { + company_domain: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Company website domain (e.g., stripe.com). Provide at least one identifier.', + }, + profile_url: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'LinkedIn company profile URL (e.g., https://linkedin.com/company/stripe). Provide at least one identifier.', + }, + company_name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Company name (fallback if domain/URL unavailable). Provide at least one identifier.', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'LeadMagic API Key', + }, + }, + + request: { + url: 'https://api.leadmagic.io/v1/companies/company-search', + method: 'POST', + headers: (params) => ({ + 'X-API-Key': params.apiKey, + 'Content-Type': 'application/json', + }), + body: (params) => { + const body: Record = {} + if (params.company_domain) body.company_domain = params.company_domain + if (params.profile_url) body.profile_url = params.profile_url + if (params.company_name) body.company_name = params.company_name + return body + }, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new Error( + (errorData as Record).message || + `LeadMagic API error: ${response.status} ${response.statusText}` + ) + } + const data = await response.json() + return { + success: true, + output: { + companyName: data.companyName ?? null, + companyId: data.companyId ?? null, + industry: data.industry ?? null, + employeeCount: data.employeeCount ?? null, + employeeRange: data.employeeRange ?? null, + founded: data.founded ?? null, + headquarters: data.headquarters ?? null, + revenue: data.revenue ?? null, + funding: data.funding ?? null, + description: data.description ?? null, + specialties: data.specialties ?? [], + competitors: data.competitors ?? [], + followerCount: data.followerCount ?? null, + twitter_url: data.twitter_url ?? null, + facebook_url: data.facebook_url ?? null, + b2b_profile_url: data.b2b_profile_url ?? null, + logo_url: data.logo_url ?? null, + credits_consumed: data.credits_consumed ?? 0, + message: data.message ?? null, + }, + } + }, + + outputs: { + companyName: { type: 'string', description: 'Company name', optional: true }, + companyId: { type: 'number', description: 'Internal company identifier', optional: true }, + industry: { type: 'string', description: 'Industry classification', optional: true }, + employeeCount: { type: 'number', description: 'Number of employees', optional: true }, + employeeRange: { + type: 'string', + description: 'Headcount range (e.g., 1001-5000)', + optional: true, + }, + founded: { type: 'number', description: 'Year the company was founded', optional: true }, + headquarters: { type: 'json', description: 'Headquarters location object', optional: true }, + revenue: { type: 'string', description: 'Revenue range', optional: true }, + funding: { type: 'string', description: 'Total funding amount', optional: true }, + description: { type: 'string', description: 'Company description', optional: true }, + specialties: { type: 'array', description: 'Company specialties and focus areas' }, + competitors: { type: 'array', description: 'Competitor companies' }, + followerCount: { type: 'number', description: 'LinkedIn follower count', optional: true }, + twitter_url: { type: 'string', description: 'Twitter/X profile URL', optional: true }, + facebook_url: { type: 'string', description: 'Facebook page URL', optional: true }, + b2b_profile_url: { + type: 'string', + description: 'LinkedIn company profile URL', + optional: true, + }, + logo_url: { type: 'string', description: 'Company logo URL', optional: true }, + credits_consumed: { type: 'number', description: 'Credits charged (1 when company found)' }, + message: { type: 'string', description: 'Human-readable status message', optional: true }, + }, +} diff --git a/apps/sim/tools/leadmagic/email_to_profile.ts b/apps/sim/tools/leadmagic/email_to_profile.ts new file mode 100644 index 00000000000..51d11a61b2f --- /dev/null +++ b/apps/sim/tools/leadmagic/email_to_profile.ts @@ -0,0 +1,89 @@ +import { leadmagicHosting } from '@/tools/leadmagic/hosting' +import type { + LeadMagicEmailToProfileParams, + LeadMagicEmailToProfileResponse, +} from '@/tools/leadmagic/types' +import type { ToolConfig } from '@/tools/types' + +export const emailToProfileTool: ToolConfig< + LeadMagicEmailToProfileParams, + LeadMagicEmailToProfileResponse +> = { + id: 'leadmagic_email_to_profile', + name: 'LeadMagic Email to Profile', + description: + 'Retrieve a LinkedIn profile URL from a work or personal email address. Charges 10 credits when a profile is found; free when no result.', + version: '1.0.0', + + hosting: leadmagicHosting((_params, output) => { + // 10 credits when profile found, 0 otherwise. + // Source: https://leadmagic.io/docs/v1/reference/email-to-profile + const consumed = output.credits_consumed + return typeof consumed === 'number' ? consumed : output.profile_url ? 10 : 0 + }), + + params: { + work_email: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Work email address (provide at least one of work_email or personal_email)', + }, + personal_email: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Personal email address (provide at least one of work_email or personal_email)', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'LeadMagic API Key', + }, + }, + + request: { + url: 'https://api.leadmagic.io/v1/people/b2b-profile', + method: 'POST', + headers: (params) => ({ + 'X-API-Key': params.apiKey, + 'Content-Type': 'application/json', + }), + body: (params) => { + const body: Record = {} + if (params.work_email) body.work_email = params.work_email + if (params.personal_email) body.personal_email = params.personal_email + return body + }, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new Error( + (errorData as Record).message || + `LeadMagic API error: ${response.status} ${response.statusText}` + ) + } + const data = await response.json() + return { + success: true, + output: { + profile_url: data.profile_url ?? null, + credits_consumed: data.credits_consumed ?? 0, + message: data.message ?? null, + }, + } + }, + + outputs: { + profile_url: { + type: 'string', + description: 'LinkedIn profile URL for the provided email', + optional: true, + }, + credits_consumed: { type: 'number', description: 'Credits charged (10 when profile found)' }, + message: { type: 'string', description: 'Human-readable status message', optional: true }, + }, +} diff --git a/apps/sim/tools/leadmagic/find_email.ts b/apps/sim/tools/leadmagic/find_email.ts new file mode 100644 index 00000000000..dae11024ad3 --- /dev/null +++ b/apps/sim/tools/leadmagic/find_email.ts @@ -0,0 +1,130 @@ +import { leadmagicHosting } from '@/tools/leadmagic/hosting' +import type { LeadMagicFindEmailParams, LeadMagicFindEmailResponse } from '@/tools/leadmagic/types' +import type { ToolConfig } from '@/tools/types' + +export const findEmailTool: ToolConfig = { + id: 'leadmagic_find_email', + name: 'LeadMagic Find Email', + description: + "Find someone's verified work email from their name and company domain. Charges 1 credit when a valid email is found; free when no result.", + version: '1.0.0', + + hosting: leadmagicHosting((_params, output) => { + // 1 credit per valid email found, 0 credits when not found. + // Source: https://leadmagic.io/docs/v1/reference/email-finder + const consumed = output.credits_consumed + return typeof consumed === 'number' ? consumed : output.email ? 1 : 0 + }), + + params: { + first_name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: "Person's first name (use with last_name, or use full_name instead)", + }, + last_name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: "Person's last name (use with first_name, or use full_name instead)", + }, + full_name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: "Person's full name (alternative to first_name + last_name)", + }, + domain: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Company domain (preferred, e.g. stripe.com)', + }, + company_name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Company name (fallback if domain is unavailable)', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'LeadMagic API Key', + }, + }, + + request: { + url: 'https://api.leadmagic.io/v1/people/email-finder', + method: 'POST', + headers: (params) => ({ + 'X-API-Key': params.apiKey, + 'Content-Type': 'application/json', + }), + body: (params) => { + const body: Record = {} + if (params.first_name) body.first_name = params.first_name + if (params.last_name) body.last_name = params.last_name + if (params.full_name) body.full_name = params.full_name + if (params.domain) body.domain = params.domain + if (params.company_name) body.company_name = params.company_name + return body + }, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new Error( + (errorData as Record).message || + `LeadMagic API error: ${response.status} ${response.statusText}` + ) + } + const data = await response.json() + return { + success: true, + output: { + email: data.email ?? null, + status: data.status ?? null, + credits_consumed: data.credits_consumed ?? 0, + message: data.message ?? null, + employment_verified: data.employment_verified ?? null, + has_mx: data.has_mx ?? null, + mx_record: data.mx_record ?? null, + mx_provider: data.mx_provider ?? null, + company_name: data.company_name ?? null, + company_industry: data.company_industry ?? null, + company_size: data.company_size ?? null, + company_profile_url: data.company_profile_url ?? null, + }, + } + }, + + outputs: { + email: { type: 'string', description: 'Found work email address', optional: true }, + status: { type: 'string', description: 'Result status (valid, invalid, etc.)', optional: true }, + credits_consumed: { type: 'number', description: 'Credits charged (1 when email found)' }, + message: { type: 'string', description: 'Human-readable status message', optional: true }, + employment_verified: { + type: 'boolean', + description: 'Whether employment at the company was verified', + optional: true, + }, + has_mx: { + type: 'boolean', + description: 'Whether the domain has a valid MX record', + optional: true, + }, + mx_record: { type: 'string', description: 'MX record for the email domain', optional: true }, + mx_provider: { type: 'string', description: 'Email provider', optional: true }, + company_name: { type: 'string', description: 'Company name', optional: true }, + company_industry: { type: 'string', description: 'Company industry', optional: true }, + company_size: { type: 'string', description: 'Company size range', optional: true }, + company_profile_url: { + type: 'string', + description: 'Company LinkedIn/B2B profile URL', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/leadmagic/find_mobile.ts b/apps/sim/tools/leadmagic/find_mobile.ts new file mode 100644 index 00000000000..859959a46b2 --- /dev/null +++ b/apps/sim/tools/leadmagic/find_mobile.ts @@ -0,0 +1,101 @@ +import { leadmagicHosting } from '@/tools/leadmagic/hosting' +import type { + LeadMagicFindMobileParams, + LeadMagicFindMobileResponse, +} from '@/tools/leadmagic/types' +import type { ToolConfig } from '@/tools/types' + +export const findMobileTool: ToolConfig = { + id: 'leadmagic_find_mobile', + name: 'LeadMagic Find Mobile', + description: + "Find a person's direct mobile number from their LinkedIn profile URL or email. Charges 5 credits when a number is found; free when no result.", + version: '1.0.0', + + hosting: leadmagicHosting((_params, output) => { + // 5 credits per mobile number found, 0 when not found. + // Source: https://leadmagic.io/docs/v1/reference/mobile-finder + const consumed = output.credits_consumed + return typeof consumed === 'number' ? consumed : output.mobile_number ? 5 : 0 + }), + + params: { + profile_url: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'LinkedIn profile URL (provide at least one identifier)', + }, + work_email: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Work email address (provide at least one identifier)', + }, + personal_email: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Personal email address (provide at least one identifier)', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'LeadMagic API Key', + }, + }, + + request: { + url: 'https://api.leadmagic.io/v1/people/mobile-finder', + method: 'POST', + headers: (params) => ({ + 'X-API-Key': params.apiKey, + 'Content-Type': 'application/json', + }), + body: (params) => { + const body: Record = {} + if (params.profile_url) body.profile_url = params.profile_url + if (params.work_email) body.work_email = params.work_email + if (params.personal_email) body.personal_email = params.personal_email + return body + }, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new Error( + (errorData as Record).message || + `LeadMagic API error: ${response.status} ${response.statusText}` + ) + } + const data = await response.json() + return { + success: true, + output: { + profile_url: data.profile_url ?? null, + email: data.email ?? null, + mobile_number: data.mobile_number ?? null, + credits_consumed: data.credits_consumed ?? 0, + message: data.message ?? null, + }, + } + }, + + outputs: { + profile_url: { + type: 'string', + description: 'LinkedIn profile URL used for lookup', + optional: true, + }, + email: { + type: 'string', + description: 'Email address associated with the profile', + optional: true, + }, + mobile_number: { type: 'string', description: 'Direct mobile phone number', optional: true }, + credits_consumed: { type: 'number', description: 'Credits charged (5 when mobile found)' }, + message: { type: 'string', description: 'Status message from the API', optional: true }, + }, +} diff --git a/apps/sim/tools/leadmagic/get_credits.ts b/apps/sim/tools/leadmagic/get_credits.ts new file mode 100644 index 00000000000..e680c7f1445 --- /dev/null +++ b/apps/sim/tools/leadmagic/get_credits.ts @@ -0,0 +1,51 @@ +import type { + LeadMagicGetCreditsParams, + LeadMagicGetCreditsResponse, +} from '@/tools/leadmagic/types' +import type { ToolConfig } from '@/tools/types' + +export const getCreditsTool: ToolConfig = { + id: 'leadmagic_get_credits', + name: 'LeadMagic Get Credits', + description: + 'Retrieve the current credit balance for the authenticated LeadMagic account. This endpoint is free and consumes no credits.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'LeadMagic API Key', + }, + }, + + request: { + url: 'https://api.leadmagic.io/v1/credits', + method: 'GET', + headers: (params) => ({ + 'X-API-Key': params.apiKey, + }), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new Error( + (errorData as Record).message || + `LeadMagic API error: ${response.status} ${response.statusText}` + ) + } + const data = await response.json() + return { + success: true, + output: { + credits: data.credits ?? 0, + }, + } + }, + + outputs: { + credits: { type: 'number', description: 'Current credit balance' }, + }, +} diff --git a/apps/sim/tools/leadmagic/hosting.ts b/apps/sim/tools/leadmagic/hosting.ts new file mode 100644 index 00000000000..b6875d9e158 --- /dev/null +++ b/apps/sim/tools/leadmagic/hosting.ts @@ -0,0 +1,48 @@ +import type { ToolHostingConfig } from '@/tools/types' + +/** + * Env var prefix for LeadMagic hosted keys. Provide keys as + * `LEADMAGIC_API_KEY_COUNT` plus `LEADMAGIC_API_KEY_1..N`. + */ +export const LEADMAGIC_API_KEY_PREFIX = 'LEADMAGIC_API_KEY' + +/** + * Dollar cost of a single LeadMagic credit. + * + * LeadMagic charges only when data is found (not_found results are free). + * Per-credit rate varies by plan: Basic ≈ $0.0204, Essential ≈ $0.0165, + * Growth ≈ $0.0104 ("from $0.007" at enterprise scale). + * We use the Growth-tier rate as a conservative representative estimate. + * + * Source: https://leadmagic.io/pricing + */ +export const LEADMAGIC_CREDIT_USD = 0.0104 + +/** + * Build a LeadMagic `hosting` config. `getCredits` returns the number of + * LeadMagic credits the call consumed, derived from the tool's output (per the + * documented per-endpoint credit model at https://leadmagic.io/docs). + * + * LeadMagic responses include a `credits_consumed` field on every endpoint. + * When no result is found, `credits_consumed` is 0. + */ +export function leadmagicHosting

( + getCredits: (params: P, output: Record) => number +): ToolHostingConfig

{ + return { + envKeyPrefix: LEADMAGIC_API_KEY_PREFIX, + apiKeyParam: 'apiKey', + byokProviderId: 'leadmagic', + pricing: { + type: 'custom', + getCost: (params, output) => { + const credits = getCredits(params, output) + return { cost: credits * LEADMAGIC_CREDIT_USD, metadata: { credits } } + }, + }, + rateLimit: { + mode: 'per_request', + requestsPerMinute: 60, + }, + } +} diff --git a/apps/sim/tools/leadmagic/index.ts b/apps/sim/tools/leadmagic/index.ts new file mode 100644 index 00000000000..bac5a556ca9 --- /dev/null +++ b/apps/sim/tools/leadmagic/index.ts @@ -0,0 +1,21 @@ +export * from './types' + +import { companySearchTool } from '@/tools/leadmagic/company_search' +import { emailToProfileTool } from '@/tools/leadmagic/email_to_profile' +import { findEmailTool } from '@/tools/leadmagic/find_email' +import { findMobileTool } from '@/tools/leadmagic/find_mobile' +import { getCreditsTool } from '@/tools/leadmagic/get_credits' +import { profileSearchTool } from '@/tools/leadmagic/profile_search' +import { profileToEmailTool } from '@/tools/leadmagic/profile_to_email' +import { roleFinderTool } from '@/tools/leadmagic/role_finder' +import { validateEmailTool } from '@/tools/leadmagic/validate_email' + +export const leadmagicValidateEmailTool = validateEmailTool +export const leadmagicFindEmailTool = findEmailTool +export const leadmagicFindMobileTool = findMobileTool +export const leadmagicProfileSearchTool = profileSearchTool +export const leadmagicProfileToEmailTool = profileToEmailTool +export const leadmagicEmailToProfileTool = emailToProfileTool +export const leadmagicCompanySearchTool = companySearchTool +export const leadmagicRoleFinderTool = roleFinderTool +export const leadmagicGetCreditsTool = getCreditsTool diff --git a/apps/sim/tools/leadmagic/profile_search.ts b/apps/sim/tools/leadmagic/profile_search.ts new file mode 100644 index 00000000000..aa52b679f21 --- /dev/null +++ b/apps/sim/tools/leadmagic/profile_search.ts @@ -0,0 +1,128 @@ +import { leadmagicHosting } from '@/tools/leadmagic/hosting' +import type { + LeadMagicProfileSearchParams, + LeadMagicProfileSearchResponse, +} from '@/tools/leadmagic/types' +import type { ToolConfig } from '@/tools/types' + +export const profileSearchTool: ToolConfig< + LeadMagicProfileSearchParams, + LeadMagicProfileSearchResponse +> = { + id: 'leadmagic_profile_search', + name: 'LeadMagic Profile Search', + description: + 'Enrich a LinkedIn profile with work history, education, skills, and contact data. Charges 1 credit per successful enrichment; free when profile not found.', + version: '1.0.0', + + hosting: leadmagicHosting((_params, output) => { + // 1 credit per successful enrichment, 0 when not found. + // Source: https://leadmagic.io/docs/v1/reference/profile-search + const consumed = output.credits_consumed + return typeof consumed === 'number' ? consumed : output.full_name ? 1 : 0 + }), + + params: { + profile_url: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'LinkedIn profile URL or username (e.g., https://linkedin.com/in/johndoe)', + }, + extended_response: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Include additional profile image URL in the response (default: false)', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'LeadMagic API Key', + }, + }, + + request: { + url: 'https://api.leadmagic.io/v1/people/profile-search', + method: 'POST', + headers: (params) => ({ + 'X-API-Key': params.apiKey, + 'Content-Type': 'application/json', + }), + body: (params) => { + const body: Record = { profile_url: params.profile_url } + if (params.extended_response !== undefined) body.extended_response = params.extended_response + return body + }, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new Error( + (errorData as Record).message || + `LeadMagic API error: ${response.status} ${response.statusText}` + ) + } + const data = await response.json() + return { + success: true, + output: { + profile_url: data.profile_url ?? null, + first_name: data.first_name ?? null, + last_name: data.last_name ?? null, + full_name: data.full_name ?? null, + professional_title: data.professional_title ?? null, + bio: data.bio ?? null, + location: data.location ?? null, + country: data.country ?? null, + followers_range: data.followers_range ?? null, + company_name: data.company_name ?? null, + company_industry: data.company_industry ?? null, + company_website: data.company_website ?? null, + total_tenure_years: data.total_tenure_years ?? null, + total_tenure_months: data.total_tenure_months ?? null, + work_experience: data.work_experience ?? [], + education: data.education ?? [], + certifications: data.certifications ?? [], + credits_consumed: data.credits_consumed ?? 0, + message: data.message ?? null, + }, + } + }, + + outputs: { + profile_url: { type: 'string', description: 'LinkedIn profile URL', optional: true }, + first_name: { type: 'string', description: 'First name', optional: true }, + last_name: { type: 'string', description: 'Last name', optional: true }, + full_name: { type: 'string', description: 'Full name', optional: true }, + professional_title: { type: 'string', description: 'Current job title', optional: true }, + bio: { type: 'string', description: 'Profile bio / summary', optional: true }, + location: { type: 'string', description: 'Location string', optional: true }, + country: { type: 'string', description: 'Country', optional: true }, + followers_range: { type: 'string', description: 'LinkedIn follower range', optional: true }, + company_name: { type: 'string', description: 'Current employer', optional: true }, + company_industry: { + type: 'string', + description: 'Industry of current employer', + optional: true, + }, + company_website: { type: 'string', description: 'Company website', optional: true }, + total_tenure_years: { + type: 'string', + description: 'Total professional tenure in years', + optional: true, + }, + total_tenure_months: { + type: 'string', + description: 'Total professional tenure in months', + optional: true, + }, + work_experience: { type: 'array', description: 'Work history entries' }, + education: { type: 'array', description: 'Education history entries' }, + certifications: { type: 'array', description: 'Professional certifications' }, + credits_consumed: { type: 'number', description: 'Credits charged (1 when profile found)' }, + message: { type: 'string', description: 'Human-readable status message', optional: true }, + }, +} diff --git a/apps/sim/tools/leadmagic/profile_to_email.ts b/apps/sim/tools/leadmagic/profile_to_email.ts new file mode 100644 index 00000000000..0d3acd64138 --- /dev/null +++ b/apps/sim/tools/leadmagic/profile_to_email.ts @@ -0,0 +1,84 @@ +import { leadmagicHosting } from '@/tools/leadmagic/hosting' +import type { + LeadMagicProfileToEmailParams, + LeadMagicProfileToEmailResponse, +} from '@/tools/leadmagic/types' +import type { ToolConfig } from '@/tools/types' + +export const profileToEmailTool: ToolConfig< + LeadMagicProfileToEmailParams, + LeadMagicProfileToEmailResponse +> = { + id: 'leadmagic_profile_to_email', + name: 'LeadMagic Profile to Email', + description: + 'Extract a verified work email from a LinkedIn profile URL. Charges 5 credits when an email is found; free when no result.', + version: '1.0.0', + + hosting: leadmagicHosting((_params, output) => { + // 5 credits when email found, 0 otherwise. + // Source: https://leadmagic.io/docs/v1/reference/profile-to-email + const consumed = output.credits_consumed + return typeof consumed === 'number' ? consumed : output.email ? 5 : 0 + }), + + params: { + profile_url: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'LinkedIn profile URL or username (e.g., https://linkedin.com/in/johndoe)', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'LeadMagic API Key', + }, + }, + + request: { + url: 'https://api.leadmagic.io/v1/people/b2b-profile-email', + method: 'POST', + headers: (params) => ({ + 'X-API-Key': params.apiKey, + 'Content-Type': 'application/json', + }), + body: (params) => ({ profile_url: params.profile_url }), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new Error( + (errorData as Record).message || + `LeadMagic API error: ${response.status} ${response.statusText}` + ) + } + const data = await response.json() + return { + success: true, + output: { + email: data.email ?? null, + profile_url: data.profile_url ?? null, + credits_consumed: data.credits_consumed ?? 0, + message: data.message ?? null, + }, + } + }, + + outputs: { + email: { + type: 'string', + description: 'Work email address found for this profile', + optional: true, + }, + profile_url: { + type: 'string', + description: 'LinkedIn profile URL used for lookup', + optional: true, + }, + credits_consumed: { type: 'number', description: 'Credits charged (5 when email found)' }, + message: { type: 'string', description: 'Human-readable status message', optional: true }, + }, +} diff --git a/apps/sim/tools/leadmagic/role_finder.ts b/apps/sim/tools/leadmagic/role_finder.ts new file mode 100644 index 00000000000..0eaee0e55a3 --- /dev/null +++ b/apps/sim/tools/leadmagic/role_finder.ts @@ -0,0 +1,100 @@ +import { leadmagicHosting } from '@/tools/leadmagic/hosting' +import type { + LeadMagicRoleFinderParams, + LeadMagicRoleFinderResponse, +} from '@/tools/leadmagic/types' +import type { ToolConfig } from '@/tools/types' + +export const roleFinderTool: ToolConfig = { + id: 'leadmagic_role_finder', + name: 'LeadMagic Role Finder', + description: + 'Find the person holding a specific job role at a company. Charges 2 credits when a matching person is found; free when no result.', + version: '1.0.0', + + hosting: leadmagicHosting((_params, output) => { + // 2 credits when a person is found, 0 otherwise. + // Source: https://leadmagic.io/docs/v1/reference/role-finder + const consumed = output.credits_consumed + return typeof consumed === 'number' ? consumed : output.full_name ? 2 : 0 + }), + + params: { + job_title: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Job role to search for (e.g., Head of Sales, CTO). Supports partial matching.', + }, + company_domain: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Company website domain (e.g., stripe.com). Provide domain or company_name.', + }, + company_name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Company name (fallback if domain unavailable). Provide domain or company_name.', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'LeadMagic API Key', + }, + }, + + request: { + url: 'https://api.leadmagic.io/v1/people/role-finder', + method: 'POST', + headers: (params) => ({ + 'X-API-Key': params.apiKey, + 'Content-Type': 'application/json', + }), + body: (params) => { + const body: Record = { job_title: params.job_title } + if (params.company_domain) body.company_domain = params.company_domain + if (params.company_name) body.company_name = params.company_name + return body + }, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new Error( + (errorData as Record).message || + `LeadMagic API error: ${response.status} ${response.statusText}` + ) + } + const data = await response.json() + return { + success: true, + output: { + first_name: data.first_name ?? null, + last_name: data.last_name ?? null, + full_name: data.full_name ?? null, + profile_url: data.profile_url ?? null, + job_title: data.job_title ?? null, + company_name: data.company_name ?? null, + company_website: data.company_website ?? null, + credits_consumed: data.credits_consumed ?? 0, + message: data.message ?? null, + }, + } + }, + + outputs: { + first_name: { type: 'string', description: 'First name of the person found', optional: true }, + last_name: { type: 'string', description: 'Last name of the person found', optional: true }, + full_name: { type: 'string', description: 'Full name of the person found', optional: true }, + profile_url: { type: 'string', description: 'LinkedIn profile URL', optional: true }, + job_title: { type: 'string', description: 'Verified job title at the company', optional: true }, + company_name: { type: 'string', description: 'Company name', optional: true }, + company_website: { type: 'string', description: 'Company website', optional: true }, + credits_consumed: { type: 'number', description: 'Credits charged (2 when person found)' }, + message: { type: 'string', description: 'Human-readable status message', optional: true }, + }, +} diff --git a/apps/sim/tools/leadmagic/types.ts b/apps/sim/tools/leadmagic/types.ts new file mode 100644 index 00000000000..394106f38a5 --- /dev/null +++ b/apps/sim/tools/leadmagic/types.ts @@ -0,0 +1,249 @@ +import type { OutputProperty, ToolResponse } from '@/tools/types' + +interface LeadMagicBaseParams { + apiKey: string +} + +// --------------------------------------------------------------------------- +// Shared output property constants +// --------------------------------------------------------------------------- + +export const LEADMAGIC_PROFILE_OUTPUT_PROPERTIES = { + profile_url: { type: 'string', description: 'LinkedIn profile URL' }, + first_name: { type: 'string', description: 'First name' }, + last_name: { type: 'string', description: 'Last name' }, + full_name: { type: 'string', description: 'Full name' }, + professional_title: { type: 'string', description: 'Current job title', optional: true }, + bio: { type: 'string', description: 'Profile bio / summary', optional: true }, + location: { type: 'string', description: 'Location string', optional: true }, + country: { type: 'string', description: 'Country', optional: true }, + company_name: { type: 'string', description: 'Current employer', optional: true }, + company_industry: { type: 'string', description: 'Industry of current employer', optional: true }, + company_website: { type: 'string', description: 'Company website', optional: true }, +} as const satisfies Record + +// --------------------------------------------------------------------------- +// Email Validation +// --------------------------------------------------------------------------- + +export interface LeadMagicValidateEmailParams extends LeadMagicBaseParams { + email: string +} + +export interface LeadMagicValidateEmailResponse extends ToolResponse { + output: { + email: string + email_status: string + is_domain_catch_all: boolean | null + credits_consumed: number + message: string | null + mx_record: string | null + mx_provider: string | null + mx_gateway: string | null + mx_security_gateway: boolean | null + company_name: string | null + company_industry: string | null + company_size: string | null + } +} + +// --------------------------------------------------------------------------- +// Email Finder +// --------------------------------------------------------------------------- + +export interface LeadMagicFindEmailParams extends LeadMagicBaseParams { + first_name?: string + last_name?: string + full_name?: string + domain?: string + company_name?: string +} + +export interface LeadMagicFindEmailResponse extends ToolResponse { + output: { + email: string | null + status: string | null + credits_consumed: number + message: string | null + employment_verified: boolean | null + has_mx: boolean | null + mx_record: string | null + mx_provider: string | null + company_name: string | null + company_industry: string | null + company_size: string | null + company_profile_url: string | null + } +} + +// --------------------------------------------------------------------------- +// Mobile Finder +// --------------------------------------------------------------------------- + +export interface LeadMagicFindMobileParams extends LeadMagicBaseParams { + profile_url?: string + work_email?: string + personal_email?: string +} + +export interface LeadMagicFindMobileResponse extends ToolResponse { + output: { + profile_url: string | null + email: string | null + mobile_number: string | null + credits_consumed: number + message: string | null + } +} + +// --------------------------------------------------------------------------- +// Profile Search (LinkedIn enrichment by profile URL) +// --------------------------------------------------------------------------- + +export interface LeadMagicProfileSearchParams extends LeadMagicBaseParams { + profile_url: string + extended_response?: boolean +} + +export interface LeadMagicProfileSearchResponse extends ToolResponse { + output: { + profile_url: string | null + first_name: string | null + last_name: string | null + full_name: string | null + professional_title: string | null + bio: string | null + location: string | null + country: string | null + followers_range: string | null + company_name: string | null + company_industry: string | null + company_website: string | null + total_tenure_years: string | null + total_tenure_months: string | null + work_experience: unknown[] + education: unknown[] + certifications: unknown[] + credits_consumed: number + message: string | null + } +} + +// --------------------------------------------------------------------------- +// Profile to Email (LinkedIn URL → work email) +// --------------------------------------------------------------------------- + +export interface LeadMagicProfileToEmailParams extends LeadMagicBaseParams { + profile_url: string +} + +export interface LeadMagicProfileToEmailResponse extends ToolResponse { + output: { + email: string | null + profile_url: string | null + credits_consumed: number + message: string | null + } +} + +// --------------------------------------------------------------------------- +// Email to Profile (work/personal email → LinkedIn profile URL) +// --------------------------------------------------------------------------- + +export interface LeadMagicEmailToProfileParams extends LeadMagicBaseParams { + work_email?: string + personal_email?: string +} + +export interface LeadMagicEmailToProfileResponse extends ToolResponse { + output: { + profile_url: string | null + credits_consumed: number + message: string | null + } +} + +// --------------------------------------------------------------------------- +// Company Search +// --------------------------------------------------------------------------- + +export interface LeadMagicCompanySearchParams extends LeadMagicBaseParams { + company_domain?: string + profile_url?: string + company_name?: string +} + +export interface LeadMagicCompanySearchResponse extends ToolResponse { + output: { + companyName: string | null + companyId: number | null + industry: string | null + employeeCount: number | null + employeeRange: string | null + founded: number | null + headquarters: Record | null + revenue: string | null + funding: string | null + description: string | null + specialties: unknown[] + competitors: unknown[] + followerCount: number | null + twitter_url: string | null + facebook_url: string | null + b2b_profile_url: string | null + logo_url: string | null + credits_consumed: number + message: string | null + } +} + +// --------------------------------------------------------------------------- +// Role Finder +// --------------------------------------------------------------------------- + +export interface LeadMagicRoleFinderParams extends LeadMagicBaseParams { + job_title: string + company_domain?: string + company_name?: string +} + +export interface LeadMagicRoleFinderResponse extends ToolResponse { + output: { + first_name: string | null + last_name: string | null + full_name: string | null + profile_url: string | null + job_title: string | null + company_name: string | null + company_website: string | null + credits_consumed: number + message: string | null + } +} + +// --------------------------------------------------------------------------- +// Get Credits (balance check — free, no hosting) +// --------------------------------------------------------------------------- + +export interface LeadMagicGetCreditsParams extends LeadMagicBaseParams {} + +export interface LeadMagicGetCreditsResponse extends ToolResponse { + output: { + credits: number + } +} + +// --------------------------------------------------------------------------- +// Union response type +// --------------------------------------------------------------------------- + +export type LeadMagicResponse = + | LeadMagicValidateEmailResponse + | LeadMagicFindEmailResponse + | LeadMagicFindMobileResponse + | LeadMagicProfileSearchResponse + | LeadMagicProfileToEmailResponse + | LeadMagicEmailToProfileResponse + | LeadMagicCompanySearchResponse + | LeadMagicRoleFinderResponse + | LeadMagicGetCreditsResponse diff --git a/apps/sim/tools/leadmagic/validate_email.ts b/apps/sim/tools/leadmagic/validate_email.ts new file mode 100644 index 00000000000..99e22736f79 --- /dev/null +++ b/apps/sim/tools/leadmagic/validate_email.ts @@ -0,0 +1,119 @@ +import { leadmagicHosting } from '@/tools/leadmagic/hosting' +import type { + LeadMagicValidateEmailParams, + LeadMagicValidateEmailResponse, +} from '@/tools/leadmagic/types' +import type { ToolConfig } from '@/tools/types' + +export const validateEmailTool: ToolConfig< + LeadMagicValidateEmailParams, + LeadMagicValidateEmailResponse +> = { + id: 'leadmagic_validate_email', + name: 'LeadMagic Validate Email', + description: + 'Verify an email address for deliverability. Charges 0.25 credits for definitive SMTP results (valid/invalid); unknown and RFC-invalid results are free.', + version: '1.0.0', + + hosting: leadmagicHosting((_params, output) => { + // 0.25 credits for valid or SMTP-verified-invalid; free for unknown/syntax failures. + // We use the API-reported credits_consumed field. + // Source: https://leadmagic.io/docs/v1/reference/email-validation + const consumed = output.credits_consumed + return typeof consumed === 'number' ? consumed : 0 + }), + + params: { + email: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Email address to validate (e.g., john@example.com)', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'LeadMagic API Key', + }, + }, + + request: { + url: 'https://api.leadmagic.io/v1/people/email-validation', + method: 'POST', + headers: (params) => ({ + 'X-API-Key': params.apiKey, + 'Content-Type': 'application/json', + }), + body: (params) => ({ email: params.email }), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new Error( + (errorData as Record).message || + `LeadMagic API error: ${response.status} ${response.statusText}` + ) + } + const data = await response.json() + return { + success: true, + output: { + email: data.email ?? '', + email_status: data.email_status ?? '', + is_domain_catch_all: data.is_domain_catch_all ?? null, + credits_consumed: data.credits_consumed ?? 0, + message: data.message ?? null, + mx_record: data.mx_record ?? null, + mx_provider: data.mx_provider ?? null, + mx_gateway: data.mx_gateway ?? null, + mx_security_gateway: data.mx_security_gateway ?? null, + company_name: data.company_name ?? null, + company_industry: data.company_industry ?? null, + company_size: data.company_size ?? null, + }, + } + }, + + outputs: { + email: { type: 'string', description: 'The validated email address' }, + email_status: { + type: 'string', + description: 'Validation result: valid, invalid, or unknown', + }, + is_domain_catch_all: { + type: 'boolean', + description: 'Whether the domain accepts all emails (catch-all)', + optional: true, + }, + credits_consumed: { + type: 'number', + description: 'Credits charged for this request (0.25 for definitive results)', + }, + message: { type: 'string', description: 'Human-readable status message', optional: true }, + mx_record: { type: 'string', description: 'MX record for the domain', optional: true }, + mx_provider: { + type: 'string', + description: 'Email provider (e.g., Google, Microsoft)', + optional: true, + }, + mx_gateway: { + type: 'string', + description: 'MX gateway for the domain', + optional: true, + }, + mx_security_gateway: { + type: 'boolean', + description: 'Whether the domain uses a security gateway', + optional: true, + }, + company_name: { + type: 'string', + description: 'Company name associated with the email domain', + optional: true, + }, + company_industry: { type: 'string', description: 'Industry of the company', optional: true }, + company_size: { type: 'string', description: 'Company size range', optional: true }, + }, +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 1f069c3be05..5b998568743 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -635,6 +635,13 @@ import { datadogSendLogsTool, datadogSubmitMetricsTool, } from '@/tools/datadog' +import { + datagmaEnrichCompanyTool, + datagmaEnrichPersonTool, + datagmaFindEmailTool, + datagmaFindPhoneTool, + datagmaGetCreditsTool, +} from '@/tools/datagma' import { daytonaCreateSandboxTool, daytonaDeleteSandboxTool, @@ -728,6 +735,7 @@ import { dropboxSearchTool, dropboxUploadTool, } from '@/tools/dropbox' +import { dropcontactEnrichContactTool } from '@/tools/dropcontact' import { chainOfThoughtTool, predictTool, reactTool } from '@/tools/dspy' import { dubBulkCreateLinksTool, @@ -820,6 +828,7 @@ import { enrichVerifyEmailTool, } from '@/tools/enrich' import { enrichmentRunTool } from '@/tools/enrichment' +import { enrowFindEmailTool, enrowVerifyEmailTool } from '@/tools/enrow' import { evernoteCopyNoteTool, evernoteCreateNotebookTool, @@ -1521,6 +1530,7 @@ import { iamRemoveUserFromGroupTool, iamSimulatePrincipalPolicyTool, } from '@/tools/iam' +import { icypeasFindEmailTool, icypeasVerifyEmailTool } from '@/tools/icypeas' import { identityCenterCheckAssignmentDeletionStatusTool, identityCenterCheckAssignmentStatusTool, @@ -1818,6 +1828,17 @@ import { launchDarklyToggleFlagTool, launchDarklyUpdateFlagTool, } from '@/tools/launchdarkly' +import { + leadmagicCompanySearchTool, + leadmagicEmailToProfileTool, + leadmagicFindEmailTool, + leadmagicFindMobileTool, + leadmagicGetCreditsTool, + leadmagicProfileSearchTool, + leadmagicProfileToEmailTool, + leadmagicRoleFinderTool, + leadmagicValidateEmailTool, +} from '@/tools/leadmagic' import { lemlistGetActivitiesTool, lemlistGetLeadTool, lemlistSendEmailTool } from '@/tools/lemlist' import { linearAddLabelToIssueTool, @@ -6494,6 +6515,25 @@ export const tools: Record = { prospeo_search_person: prospeoSearchPersonTool, prospeo_search_company: prospeoSearchCompanyTool, prospeo_search_suggestions: prospeoSearchSuggestionsTool, + datagma_find_email: datagmaFindEmailTool, + datagma_find_phone: datagmaFindPhoneTool, + datagma_enrich_person: datagmaEnrichPersonTool, + datagma_enrich_company: datagmaEnrichCompanyTool, + datagma_get_credits: datagmaGetCreditsTool, + dropcontact_enrich_contact: dropcontactEnrichContactTool, + leadmagic_validate_email: leadmagicValidateEmailTool, + leadmagic_find_email: leadmagicFindEmailTool, + leadmagic_find_mobile: leadmagicFindMobileTool, + leadmagic_profile_search: leadmagicProfileSearchTool, + leadmagic_profile_to_email: leadmagicProfileToEmailTool, + leadmagic_email_to_profile: leadmagicEmailToProfileTool, + leadmagic_company_search: leadmagicCompanySearchTool, + leadmagic_role_finder: leadmagicRoleFinderTool, + leadmagic_get_credits: leadmagicGetCreditsTool, + icypeas_find_email: icypeasFindEmailTool, + icypeas_verify_email: icypeasVerifyEmailTool, + enrow_find_email: enrowFindEmailTool, + enrow_verify_email: enrowVerifyEmailTool, iam_list_users: iamListUsersTool, iam_get_user: iamGetUserTool, iam_create_user: iamCreateUserTool, diff --git a/apps/sim/tools/types.ts b/apps/sim/tools/types.ts index c8da61e06cc..dcce33cbe20 100644 --- a/apps/sim/tools/types.ts +++ b/apps/sim/tools/types.ts @@ -30,6 +30,11 @@ export type BYOKProviderId = | 'zerobounce' | 'neverbounce' | 'millionverifier' + | 'datagma' + | 'dropcontact' + | 'leadmagic' + | 'icypeas' + | 'enrow' export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD'