mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
feat(integrations): hosted email-enrichment providers + cascade wiring (#5087)
* feat(integrations): hosted email-enrichment providers + cascade wiring Add Datagma, Dropcontact, LeadMagic, Icypeas, and Enrow integrations — tools, blocks, brand icons, and BYOK + metered hosted-key support — and register each in the tool/block registries and BYOK provider list. Wire the new finders/verifiers into the enrichment cascades: - work-email: Datagma, LeadMagic, Dropcontact, Icypeas, Enrow - phone-number: LeadMagic, Datagma, Dropcontact - email-verification: Icypeas, Enrow - company-info: Datagma, LeadMagic - company-domain: Datagma Add hosting tests for all five providers and cascade tests covering the new providers (incl. new test files for email-verification, company-info, and company-domain). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(enrichment): address PR review on Icypeas success + Datagma billing - Icypeas find_email/verify_email postProcess return success:true for all terminal statuses (NOT_FOUND/DEBITED_NOT_FOUND included) so the cascade runner calls mapOutput and records invalid/not-found verdicts instead of throwing and inflating the error count - Bill Icypeas verify FOUND (not just DEBITED*) per the documented 0.1-credit charge - Datagma enrich_person only applies the 30-credit phone surcharge when a phone lookup (phoneFull) was requested - Note Datagma's URL-param (apiId) auth in the hosted-key doc comment - Update hosting tests to match Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(enrichment): only bill Enrow verify on a completed verification getCost returned a flat 0.25 credits regardless of output, so a job that fell back to the initial submit response (poll never completed, no qualification) was still metered. Charge 0.25 only when a qualification is present; 0 otherwise. Add a no-qualification test case. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(enrichment): peg hosted credit cost to each provider's lowest paid plan Align *_CREDIT_USD to the entry tier Sim will provision: - Datagma: Regular $49/3,000 emails → $0.0163 (was Popular $0.0132) - LeadMagic: Basic $49/2,000 → $0.0245 (was Growth $0.0104) Icypeas (Basic $0.019), Enrow (Starter $0.012), and Dropcontact (Starter ~$0.17) already reflect their lowest plan. Tests derive from the constants, so values stay consistent. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(enrichment): address PR review on mononyms + Icypeas verify email map - work-email LeadMagic: pass full_name + domain so single-token (mononym) names are no longer skipped - work-email Icypeas: firstname/lastname are optional on the API, so run a mononym with firstname alone instead of self-skipping - icypeas_verify_email mapItem reads item.email (verify payload shape) with a fallback to the nested results.emails[0].email Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(enrichment): case-insensitive Enrow find billing getCost compared qualification to exactly 'valid' while the cascade normalizes with toLowerCase(), so a differently-cased API qualifier could zero out billing on a valid email. Lowercase before comparing; add a test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(enrichment): drop Dropcontact from the phone-number cascade Dropcontact is an email/company-data enrichment service, not a phone-discovery provider — its phone/mobile_phone fields are unreliable and were surfacing firmographic data (an employee-count range like "5000-20077") as the phone. Keep the two purpose-built phone finders (LeadMagic find_mobile, Datagma find_phone); Dropcontact stays in the work-email and company cascades where its data is reliable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(enrichment): only accept valid-qualified Enrow emails in work-email Enrow's finder qualifies each email valid/invalid. The work-email mapOutput accepted any non-empty email, so an invalid-qualified address could fill the cell while hosted billing (which only charges on valid) charged zero. Gate the cell on qualification === 'valid', consistent with billing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
feca5fa6f6
commit
15a970d805
@@ -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',
|
||||
|
||||
@@ -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<DatagmaResponse> = {
|
||||
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<string, string> = {
|
||||
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<string, unknown> = {}
|
||||
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
|
||||
@@ -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<DropcontactResponse> = {
|
||||
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<string, unknown> = {}
|
||||
|
||||
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
|
||||
@@ -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<EnrowResponse> = {
|
||||
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<string, string> = {
|
||||
ve_email: 'email',
|
||||
}
|
||||
|
||||
const result: Record<string, unknown> = {}
|
||||
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
|
||||
@@ -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<IcypeasResponse> = {
|
||||
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<string, string> = {
|
||||
fe_firstname: 'firstname',
|
||||
fe_lastname: 'lastname',
|
||||
fe_domainOrCompany: 'domainOrCompany',
|
||||
ve_email: 'email',
|
||||
}
|
||||
|
||||
const result: Record<string, unknown> = {}
|
||||
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
|
||||
@@ -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<LeadMagicResponse> = {
|
||||
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<string, string> = {
|
||||
// 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<string, unknown> = {}
|
||||
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
|
||||
@@ -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<string, BlockConfig> = {
|
||||
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<string, BlockConfig> = {
|
||||
emailbison: EmailBisonBlock,
|
||||
enrich: EnrichBlock,
|
||||
enrichment: EnrichmentBlock,
|
||||
enrow: EnrowBlock,
|
||||
evaluator: EvaluatorBlock,
|
||||
evernote: EvernoteBlock,
|
||||
exa: ExaBlock,
|
||||
@@ -454,6 +462,7 @@ const BLOCK_REGISTRY: Record<string, BlockConfig> = {
|
||||
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<string, BlockConfig> = {
|
||||
langsmith: LangsmithBlock,
|
||||
latex: LatexBlock,
|
||||
launchdarkly: LaunchDarklyBlock,
|
||||
leadmagic: LeadMagicBlock,
|
||||
lemlist: LemlistBlock,
|
||||
linear: LinearBlock,
|
||||
linear_v2: LinearV2Block,
|
||||
@@ -678,11 +688,13 @@ const BLOCK_META_REGISTRY: Record<string, BlockMeta> = {
|
||||
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<string, BlockMeta> = {
|
||||
emailbison: EmailBisonBlockMeta,
|
||||
enrich: EnrichBlockMeta,
|
||||
enrichment: EnrichmentBlockMeta,
|
||||
enrow: EnrowBlockMeta,
|
||||
evernote: EvernoteBlockMeta,
|
||||
exa: ExaBlockMeta,
|
||||
extend: ExtendBlockMeta,
|
||||
@@ -738,6 +751,7 @@ const BLOCK_META_REGISTRY: Record<string, BlockMeta> = {
|
||||
huggingface: HuggingFaceBlockMeta,
|
||||
hunter: HunterBlockMeta,
|
||||
iam: IAMBlockMeta,
|
||||
icypeas: IcypeasBlockMeta,
|
||||
identity_center: IdentityCenterBlockMeta,
|
||||
imap: ImapBlockMeta,
|
||||
incidentio: IncidentioBlockMeta,
|
||||
@@ -754,6 +768,7 @@ const BLOCK_META_REGISTRY: Record<string, BlockMeta> = {
|
||||
langsmith: LangsmithBlockMeta,
|
||||
latex: LatexBlockMeta,
|
||||
launchdarkly: LaunchDarklyBlockMeta,
|
||||
leadmagic: LeadMagicBlockMeta,
|
||||
lemlist: LemlistBlockMeta,
|
||||
linear: LinearBlockMeta,
|
||||
linkedin: LinkedInBlockMeta,
|
||||
|
||||
@@ -7870,3 +7870,114 @@ export function TriggerDevIcon(props: SVGProps<SVGSVGElement>) {
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** Datagma brand icon: navy square with the white Datagma "D" mark. */
|
||||
export function DatagmaIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
<rect width='24' height='24' fill='#003071' />
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
d='M6 4.5H11.5C15.9 4.5 18.5 7.8 18.5 12C18.5 16.2 15.9 19.5 11.5 19.5H6V4.5ZM9.3 7.5V16.5H11.5C13.8 16.5 15.2 14.6 15.2 12C15.2 9.4 13.8 7.5 11.5 7.5H9.3Z'
|
||||
fill='#FFFFFF'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** LeadMagic brand icon: purple gradient tile with the white spark mark. */
|
||||
export function LeadMagicIcon(props: SVGProps<SVGSVGElement>) {
|
||||
const id = useId()
|
||||
const gradient = `leadmagic_grad_${id}`
|
||||
return (
|
||||
<svg {...props} viewBox='0 0 65 65' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
<defs>
|
||||
<linearGradient id={gradient} x1='0%' y1='100%' x2='100%' y2='0%'>
|
||||
<stop offset='0%' stopColor='#4C1D95' />
|
||||
<stop offset='50%' stopColor='#7C3AED' />
|
||||
<stop offset='100%' stopColor='#C4B5FD' />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width='65' height='65' fill={`url(#${gradient})`} />
|
||||
<path
|
||||
d='M34.1016 37.707C33.5363 37.7698 33.0967 38.021 32.8454 38.5235C32.5942 39.0259 32.4686 39.654 32.5942 40.4705C32.5942 40.4705 34.29 49.8288 34.5412 50.8337C34.7925 51.8386 35.3577 53.4716 36.1114 53.346C36.9279 53.2203 36.9907 51.5245 36.9279 50.4568C36.9279 50.4568 36.237 41.601 36.1742 40.5961C36.1114 39.5284 35.8602 38.7119 35.4833 38.335C35.1065 37.8326 34.6668 37.6442 34.1016 37.707Z'
|
||||
fill='#FFFFFF'
|
||||
/>
|
||||
<path
|
||||
d='M33.5344 26.1529L35.4815 23.201C35.7955 22.6985 36.1095 22.7613 36.1095 23.1382L35.6699 26.9066C35.6071 27.2835 35.9211 27.7231 36.4236 27.6603L39.7523 26.9066C39.9408 26.9066 40.1292 27.1578 39.9408 27.3463L37.4285 29.5445C37.0516 29.9842 37.0516 30.4238 37.5541 30.7378L39.8152 32.1196C39.9408 32.2452 39.878 32.4336 39.6895 32.4336L36.9888 32.6221C36.4236 32.6849 36.1723 33.1245 36.2351 33.6898L37.1144 36.9558C37.1773 37.1442 36.9888 37.3326 36.7376 37.0814L34.4137 34.5691C33.9741 34.1922 33.4088 34.255 33.032 34.6947L31.399 36.8301C31.2734 36.9558 30.9593 36.8929 31.0221 36.7045L31.1478 34.1922C31.085 33.627 30.7081 33.1873 30.1428 33.2501L27.5049 34.0666C27.2537 34.1294 27.1909 33.8154 27.3165 33.6898L29.2635 31.5543C29.6404 31.1147 29.7032 30.6122 29.1379 30.2982L26.3116 28.9792C25.9348 28.728 26.1232 28.4768 26.4372 28.4768L29.452 28.414C29.8916 28.414 30.2685 28.0999 30.1428 27.4719C30.1428 27.4719 29.2635 24.3315 29.0751 23.8291C28.8867 23.3266 29.3892 23.0126 29.766 23.3894L32.3411 26.1529C32.7179 26.5926 33.1576 26.6554 33.5344 26.1529Z'
|
||||
fill='#FFFFFF'
|
||||
/>
|
||||
<path
|
||||
d='M32.5928 22.3851C32.3416 22.3851 32.0903 22.1339 32.0275 21.757L30.3317 13.2152C30.2689 12.8384 30.4574 12.1475 31.0854 12.0219C31.6507 11.8963 32.216 12.3359 32.2788 12.7128L32.9068 21.6314C32.9696 22.0083 32.9696 22.3851 32.5928 22.3851Z'
|
||||
fill='#FFFFFF'
|
||||
/>
|
||||
<path
|
||||
d='M40.5694 29.7335C40.5066 29.3567 40.8834 29.2939 41.2603 29.2939L49.3624 27.9121C49.8649 27.9121 50.1161 28.4146 50.1161 28.7286C50.2417 29.1054 49.7392 29.7335 49.0484 29.6707L41.3231 29.9847C41.009 29.9847 40.6322 30.1104 40.5694 29.7335Z'
|
||||
fill='#FFFFFF'
|
||||
/>
|
||||
<path
|
||||
d='M16.0122 32.3718C16.0122 31.9949 16.6402 31.2412 17.3939 31.2412L24.868 31.0528C25.2448 30.99 25.6217 31.1156 25.6217 31.4297C25.6217 31.7437 25.1192 31.8693 24.7424 31.9321L17.2055 33.1883C16.7658 33.1254 15.8865 32.8742 16.0122 32.3718Z'
|
||||
fill='#FFFFFF'
|
||||
/>
|
||||
<path
|
||||
d='M38.4972 24.2699C38.1831 24.0186 38.8112 23.265 39.0624 22.9509L43.3333 17.1099C43.5845 16.6702 44.401 16.419 44.9035 16.8586C45.2803 17.2355 45.2175 17.8007 44.9663 18.052L39.3137 24.0186C39.0624 24.2699 38.7484 24.3955 38.4972 24.2699Z'
|
||||
fill='#FFFFFF'
|
||||
/>
|
||||
<path
|
||||
d='M18.838 19.43C19.2148 18.8647 20.0313 19.0531 20.3453 19.3044L26.1864 24.5802C26.5004 24.8314 26.6261 25.2082 26.4376 25.3339C26.2492 25.4595 25.9352 25.3967 25.6211 25.2082L18.9636 20.8117C18.6496 20.5605 18.4611 19.9324 18.838 19.43Z'
|
||||
fill='#FFFFFF'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** Dropcontact brand icon: teal disc with the white open-"d" contact mark. */
|
||||
export function DropcontactIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
<circle cx='12' cy='12' r='12' fill='#0ABA9F' />
|
||||
<path
|
||||
d='M13.87 7.9A5 5 0 1 0 13.87 16.1'
|
||||
stroke='#FFFFFF'
|
||||
strokeWidth='2.4'
|
||||
strokeLinecap='round'
|
||||
/>
|
||||
<path d='M15.1 6.2V17.8' stroke='#FFFFFF' strokeWidth='2.4' strokeLinecap='round' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** Icypeas brand icon: dark tile with the teal ring + rising-chart mark. */
|
||||
export function IcypeasIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
<rect width='24' height='24' rx='5' fill='#011C21' />
|
||||
<circle cx='12' cy='12' r='7' stroke='#02C29F' strokeWidth='1.7' />
|
||||
<path
|
||||
d='M7.2 13.8H9.2V11.8H11.2V9.8H13.2V8.2'
|
||||
stroke='#02C29F'
|
||||
strokeWidth='1.7'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
<rect x='13.5' y='5.8' width='2.9' height='2.9' rx='0.8' fill='#02C29F' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** Enrow brand icon: blue tile with the three white stacked rows. */
|
||||
export function EnrowIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox='0 0 256 256' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
<path
|
||||
fill='#0056FF'
|
||||
d='M217.791 0H38.209C17.107 0 0 17.107 0 38.209v179.582C0 238.893 17.107 256 38.209 256h179.582C238.893 256 256 238.893 256 217.791V38.209C256 17.107 238.893 0 217.791 0z'
|
||||
/>
|
||||
<path
|
||||
fill='#F0F0F0'
|
||||
d='M196.877 58.88H78.055a3.82 3.82 0 0 0-3.82 3.82v17.96a3.82 3.82 0 0 0 3.82 3.82h118.822a3.82 3.82 0 0 0 3.821-3.82V62.701a3.82 3.82 0 0 0-3.821-3.821zm.007 112.641H73.454a3.82 3.82 0 0 0-3.821 3.82V193.3a3.82 3.82 0 0 0 3.82 3.821h123.431a3.821 3.821 0 0 0 3.821-3.821v-17.959a3.82 3.82 0 0 0-3.821-3.82zM141.588 115.2H58.606a3.821 3.821 0 0 0-3.82 3.821v17.958a3.82 3.82 0 0 0 3.82 3.821h82.982a3.82 3.82 0 0 0 3.821-3.821v-17.958a3.82 3.82 0 0 0-3.821-3.821z'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
}),
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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({})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
},
|
||||
}),
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
}),
|
||||
],
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ describe('phone-number enrichment cascade', () => {
|
||||
'wiza',
|
||||
'findymail',
|
||||
'prospeo',
|
||||
'leadmagic',
|
||||
'datagma',
|
||||
])
|
||||
})
|
||||
|
||||
@@ -75,4 +77,28 @@ 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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). 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,35 @@ 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
|
||||
},
|
||||
}),
|
||||
],
|
||||
}
|
||||
|
||||
@@ -23,6 +23,11 @@ describe('work-email enrichment cascade', () => {
|
||||
'prospeo',
|
||||
'wiza',
|
||||
'pdl',
|
||||
'datagma',
|
||||
'leadmagic',
|
||||
'dropcontact',
|
||||
'icypeas',
|
||||
'enrow',
|
||||
])
|
||||
})
|
||||
|
||||
@@ -83,4 +88,83 @@ 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('passes full_name + domain and keeps mononym rows', () => {
|
||||
expect(p.toolId).toBe('leadmagic_find_email')
|
||||
expect(p.buildParams(nameDomain)).toEqual({ full_name: 'John Doe', domain: 'acme.com' })
|
||||
expect(p.buildParams({ fullName: 'John Doe' })).toBeNull()
|
||||
// single-token name still runs (no longer skipped)
|
||||
expect(p.buildParams({ fullName: 'Cher', companyDomain: 'acme.com' })).toEqual({
|
||||
full_name: 'Cher',
|
||||
domain: 'acme.com',
|
||||
})
|
||||
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 when possible and keeps mononym rows', () => {
|
||||
expect(p.toolId).toBe('icypeas_find_email')
|
||||
expect(p.buildParams(nameDomain)).toEqual({
|
||||
firstname: 'John',
|
||||
lastname: 'Doe',
|
||||
domainOrCompany: 'acme.com',
|
||||
})
|
||||
// single-token name runs with firstname alone (lastname is optional)
|
||||
expect(p.buildParams({ fullName: 'Cher', companyDomain: 'acme.com' })).toEqual({
|
||||
firstname: 'Cher',
|
||||
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()
|
||||
// only a valid-qualified email fills the cell
|
||||
expect(p.mapOutput({ email: 'j@acme.com', qualification: 'valid' })).toEqual({
|
||||
email: 'j@acme.com',
|
||||
})
|
||||
expect(p.mapOutput({ email: 'j@acme.com', qualification: 'invalid' })).toBeNull()
|
||||
expect(p.mapOutput({ email: 'j@acme.com' })).toBeNull()
|
||||
expect(p.mapOutput({})).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,96 @@ 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) => {
|
||||
// LeadMagic accepts full_name + domain, so pass the whole name and let it
|
||||
// split — this keeps single-token (mononym) rows in play.
|
||||
const fullName = str(inputs.fullName)
|
||||
const domain = normalizeDomain(inputs.companyDomain)
|
||||
if (!fullName || !domain) return null
|
||||
return { full_name: fullName, 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) => {
|
||||
// Icypeas only requires domainOrCompany; firstname/lastname are optional,
|
||||
// so a mononym still runs with firstname alone rather than self-skipping.
|
||||
const fullName = str(inputs.fullName)
|
||||
const domainOrCompany = normalizeDomain(inputs.companyDomain)
|
||||
if (!fullName || !domainOrCompany) return null
|
||||
const name = splitName(inputs.fullName)
|
||||
return name
|
||||
? { firstname: name.firstName, lastname: name.lastName, domainOrCompany }
|
||||
: { firstname: fullName, 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) => {
|
||||
// Enrow qualifies each found email valid/invalid; only accept verified-valid
|
||||
// results so the cell isn't filled with an address Enrow itself rejected
|
||||
// (and which hosted billing correctly charges zero for).
|
||||
const email = str(output.email)
|
||||
const qualification = str(output.qualification).toLowerCase()
|
||||
return email && qualification === 'valid' ? { email } : null
|
||||
},
|
||||
}),
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* @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<any, any>, params: any, output: Record<string, unknown>) {
|
||||
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 a phone lookup was requested and found', () => {
|
||||
expect(
|
||||
cost(
|
||||
enrichPersonTool,
|
||||
{ phoneFull: true },
|
||||
{ name: 'John Doe', email: 'john@stripe.com', phone: '+14155551234' }
|
||||
).cost
|
||||
).toBeCloseTo(32 * DATAGMA_CREDIT_USD)
|
||||
})
|
||||
|
||||
it('does not charge the phone surcharge when phoneFull was not requested', () => {
|
||||
expect(
|
||||
cost(
|
||||
enrichPersonTool,
|
||||
{},
|
||||
{ name: 'John Doe', email: 'john@stripe.com', phone: '+14155551234' }
|
||||
).cost
|
||||
).toBeCloseTo(2 * 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)
|
||||
})
|
||||
})
|
||||
@@ -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<DatagmaEnrichCompanyParams>((_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<string, string>).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<string, unknown>
|
||||
|
||||
// Company data may be nested under a `company` key or returned at the top level
|
||||
const company = (data.company ?? data) as Record<string, unknown>
|
||||
|
||||
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 },
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
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<DatagmaEnrichPersonParams, DatagmaEnrichPersonResponse> =
|
||||
{
|
||||
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<DatagmaEnrichPersonParams>((params, output) => {
|
||||
const name = output.name as string | null
|
||||
const email = output.email as string | null
|
||||
if (!name && !email) return 0
|
||||
// The 30-credit phone surcharge applies only when the caller requested a
|
||||
// phone lookup (phoneFull); a phone that rides along otherwise isn't charged.
|
||||
const phoneCredits = params.phoneFull && 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<string, string>).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<string, unknown>
|
||||
|
||||
// Datagma nests phone numbers in an array; surface the first number's raw value
|
||||
const phones = data.phones as Array<Record<string, unknown>> | 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,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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<DatagmaFindEmailParams, DatagmaFindEmailResponse> = {
|
||||
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<DatagmaFindEmailParams>((_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<string, string>).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<string, unknown>
|
||||
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 },
|
||||
},
|
||||
}
|
||||
@@ -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<DatagmaFindPhoneParams, DatagmaFindPhoneResponse> = {
|
||||
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<DatagmaFindPhoneParams>((_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<string, string>).message ||
|
||||
`Datagma API error: ${response.status} ${response.statusText}`,
|
||||
output: { phone: null, countryCode: null, isWhatsapp: null },
|
||||
}
|
||||
}
|
||||
const data = (await response.json()) as Record<string, unknown>
|
||||
|
||||
// Phone data may be nested under a `phones` array or returned at top level
|
||||
const phones = data.phones as Array<Record<string, unknown>> | 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,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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<DatagmaGetCreditsParams, DatagmaGetCreditsResponse> = {
|
||||
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<string, string>).message ||
|
||||
`Datagma API error: ${response.status} ${response.statusText}`,
|
||||
output: { credits: null },
|
||||
}
|
||||
}
|
||||
const data = (await response.json()) as Record<string, unknown>
|
||||
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 },
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
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`.
|
||||
*
|
||||
* Note: Datagma authenticates via an `apiId` URL query parameter (its only
|
||||
* documented scheme), so the key appears verbatim in every request URL and may
|
||||
* be captured by Datagma's and any intermediary's access logs. Treat a leaked
|
||||
* key accordingly and rotate via the env vars above.
|
||||
*/
|
||||
export const DATAGMA_API_KEY_PREFIX = 'DATAGMA_API_KEY'
|
||||
|
||||
/**
|
||||
* Dollar cost of a single Datagma credit.
|
||||
*
|
||||
* Based on the entry Regular plan ($49/month, 3,000 emails ≈ $0.0163/credit);
|
||||
* per-credit drops at higher tiers (Popular/Expert) and on annual billing.
|
||||
* 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.0163
|
||||
|
||||
/**
|
||||
* 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<P>(
|
||||
getCredits: (params: P, output: Record<string, unknown>) => number
|
||||
): ToolHostingConfig<P> {
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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<any, any>, params: any, output: Record<string, unknown>) {
|
||||
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/)
|
||||
})
|
||||
})
|
||||
@@ -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<DropcontactEnrichContactParams>((_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<string, unknown> = {}
|
||||
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<string, unknown> = { 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 },
|
||||
},
|
||||
}
|
||||
@@ -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<P>(
|
||||
getCredits: (params: P, output: Record<string, unknown>) => number
|
||||
): ToolHostingConfig<P> {
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './types'
|
||||
|
||||
import { dropcontactEnrichContactTool } from '@/tools/dropcontact/enrich_contact'
|
||||
|
||||
export { dropcontactEnrichContactTool }
|
||||
@@ -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 <local>@<domain>, e.g. nominative@pro, catch_all@pro, generic@perso',
|
||||
},
|
||||
} as const satisfies Record<string, OutputProperty>
|
||||
|
||||
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
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* @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<unknown, unknown>,
|
||||
params: unknown,
|
||||
output: Record<string, unknown>
|
||||
) {
|
||||
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 (case-insensitive)', () => {
|
||||
expect(cost(enrowFindEmailTool, {}, { qualification: 'valid' }).cost).toBeCloseTo(
|
||||
1 * ENROW_CREDIT_USD
|
||||
)
|
||||
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 for a completed verification (valid or invalid)', () => {
|
||||
expect(cost(enrowVerifyEmailTool, {}, { qualification: 'valid' }).cost).toBeCloseTo(
|
||||
0.25 * ENROW_CREDIT_USD
|
||||
)
|
||||
expect(cost(enrowVerifyEmailTool, {}, { qualification: 'invalid' }).cost).toBeCloseTo(
|
||||
0.25 * ENROW_CREDIT_USD
|
||||
)
|
||||
})
|
||||
|
||||
it('charges 0 credits when the job did not complete (no qualification)', () => {
|
||||
expect(cost(enrowVerifyEmailTool, {}, { qualification: null }).cost).toBe(0)
|
||||
expect(cost(enrowVerifyEmailTool, {}, {}).cost).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
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<string, unknown>).email).toBe('john@stripe.com')
|
||||
expect((result.output as Record<string, unknown>).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<string, unknown>).qualification).toBe('valid')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,193 @@
|
||||
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<string, unknown>): 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=<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<EnrowFindEmailParams, EnrowFindEmailResponse> = {
|
||||
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<EnrowFindEmailParams>((_params, output) => {
|
||||
// 1 credit charged only when a valid email is returned. Compare
|
||||
// case-insensitively so the API's qualifier casing can't zero out billing.
|
||||
return String(output.qualification ?? '').toLowerCase() === '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<string, unknown> = { 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<EnrowFindEmailResponse> => {
|
||||
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<EnrowFindEmailResponse> => {
|
||||
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<string, unknown>) ?? {}
|
||||
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,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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<P>(
|
||||
getCredits: (params: P, output: Record<string, unknown>) => number
|
||||
): ToolHostingConfig<P> {
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './types'
|
||||
|
||||
import { enrowFindEmailTool } from '@/tools/enrow/find_email'
|
||||
import { enrowVerifyEmailTool } from '@/tools/enrow/verify_email'
|
||||
|
||||
export { enrowFindEmailTool, enrowVerifyEmailTool }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
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<string, unknown>, 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=<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<EnrowVerifyEmailParams, EnrowVerifyEmailResponse> = {
|
||||
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<EnrowVerifyEmailParams>((_params, output) => {
|
||||
// 0.25 credits per completed verification. Bill only when the job resolved
|
||||
// to a qualification — a fall-back to the initial submit response (poll never
|
||||
// finished) has no qualification and must not be charged.
|
||||
return output.qualification ? 0.25 : 0
|
||||
}),
|
||||
|
||||
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<EnrowVerifyEmailResponse> => {
|
||||
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<EnrowVerifyEmailResponse> => {
|
||||
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<string, unknown>) ?? {}
|
||||
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,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* @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<any, any>, params: any, output: Record<string, unknown>) {
|
||||
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=true with a null email 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(true)
|
||||
expect((result.output as any).status).toBe('NOT_FOUND')
|
||||
expect((result.output as any).email).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,195 @@
|
||||
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<string, unknown>): 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<string, unknown> | undefined) ?? {}
|
||||
const emails = Array.isArray(results.emails) ? (results.emails as Record<string, unknown>[]) : []
|
||||
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<IcypeasFindEmailParams, IcypeasFindEmailResponse> = {
|
||||
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<IcypeasFindEmailParams>((_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<string, unknown> = {
|
||||
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<string, unknown>
|
||||
// Submit response: { success: true, item: { _id: '...', status: 'NONE', ... } }
|
||||
const item = (json.item as Record<string, unknown> | 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<string, unknown>
|
||||
// Poll response: { success: true, item: { _id: '...', status: '...', results: { emails: [...], firstname, lastname } } }
|
||||
const item = (json.item as Record<string, unknown> | undefined) ?? {}
|
||||
const status = (item.status as string | undefined) ?? null
|
||||
|
||||
if (status && TERMINAL_STATUSES.has(status)) {
|
||||
// Any terminal status is a successful run — a clean no-match is not a
|
||||
// failure. The enrichment cascade only calls mapOutput when success is
|
||||
// true, so returning false would skip the verdict and inflate the
|
||||
// runner's error count. A null email signals "not found" downstream.
|
||||
return {
|
||||
success: true,
|
||||
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,
|
||||
},
|
||||
}
|
||||
@@ -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<P>(
|
||||
getCredits: (params: P, output: Record<string, unknown>) => number
|
||||
): ToolHostingConfig<P> {
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './types'
|
||||
|
||||
import { icypeasFindEmailTool } from '@/tools/icypeas/find_email'
|
||||
import { icypeasVerifyEmailTool } from '@/tools/icypeas/verify_email'
|
||||
|
||||
export { icypeasFindEmailTool, icypeasVerifyEmailTool }
|
||||
@@ -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<string, unknown> | 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<string, unknown> | 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,
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
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<string, unknown>): IcypeasVerifyEmailOutput {
|
||||
const status = (item.status as string | undefined) ?? null
|
||||
// Verify payloads put the address on item.email; fall back to the nested
|
||||
// results.emails[0].email shape that some responses use.
|
||||
const results = (item.results as Record<string, unknown> | undefined) ?? {}
|
||||
const emails = Array.isArray(results.emails) ? (results.emails as Record<string, unknown>[]) : []
|
||||
const email =
|
||||
(item.email as string | undefined) ?? (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<IcypeasVerifyEmailParams>((_params, output) => {
|
||||
// 0.1 credit per verification that consumed credits: FOUND/DEBITED (verdict
|
||||
// delivered) and DEBITED_NOT_FOUND (debited even though unresolved).
|
||||
// BAD_INPUT / INSUFFICIENT_FUNDS / ABORTED / NOT_FOUND are never charged.
|
||||
const status = output.status as string | undefined
|
||||
if (!status) {
|
||||
throw new Error('Icypeas verify-email: cannot determine cost — status is missing')
|
||||
}
|
||||
const billable = status === 'FOUND' || 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<string, unknown>
|
||||
// Submit response: { success: true, item: { _id: '...', status: 'NONE', ... } }
|
||||
const item = (json.item as Record<string, unknown> | 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<string, unknown>
|
||||
// Poll response: { success: true, item: { _id: '...', status: '...', results: { emails: [...] } } }
|
||||
const item = (json.item as Record<string, unknown> | undefined) ?? {}
|
||||
const status = (item.status as string | undefined) ?? null
|
||||
|
||||
if (status && TERMINAL_STATUSES.has(status)) {
|
||||
// Any terminal status is a successful run — NOT_FOUND/DEBITED_NOT_FOUND are
|
||||
// definitive verdicts, not failures. The enrichment cascade only calls
|
||||
// mapOutput when success is true, so returning false here would skip those
|
||||
// verdicts and inflate the runner's error count. `valid` carries the result.
|
||||
return {
|
||||
success: true,
|
||||
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,
|
||||
},
|
||||
}
|
||||
@@ -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<any, any>, params: any, output: Record<string, unknown>) {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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<LeadMagicCompanySearchParams>((_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<string, string> = {}
|
||||
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<string, string>).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 },
|
||||
},
|
||||
}
|
||||
@@ -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<LeadMagicEmailToProfileParams>((_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<string, string> = {}
|
||||
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<string, string>).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 },
|
||||
},
|
||||
}
|
||||
@@ -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<LeadMagicFindEmailParams, LeadMagicFindEmailResponse> = {
|
||||
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<LeadMagicFindEmailParams>((_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<string, string> = {}
|
||||
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<string, string>).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,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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<LeadMagicFindMobileParams, LeadMagicFindMobileResponse> = {
|
||||
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<LeadMagicFindMobileParams>((_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<string, string> = {}
|
||||
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<string, string>).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 },
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type {
|
||||
LeadMagicGetCreditsParams,
|
||||
LeadMagicGetCreditsResponse,
|
||||
} from '@/tools/leadmagic/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const getCreditsTool: ToolConfig<LeadMagicGetCreditsParams, LeadMagicGetCreditsResponse> = {
|
||||
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<string, string>).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' },
|
||||
},
|
||||
}
|
||||
@@ -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).
|
||||
* Based on the entry Basic plan ($49/month, 2,000 credits ≈ $0.0245/credit);
|
||||
* per-credit drops at higher tiers (Essential/Growth). Email finder: 1 credit.
|
||||
* Mobile finder: 5 credits. Company search: 1 credit.
|
||||
*
|
||||
* Source: https://leadmagic.io/pricing
|
||||
*/
|
||||
export const LEADMAGIC_CREDIT_USD = 0.0245
|
||||
|
||||
/**
|
||||
* 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<P>(
|
||||
getCredits: (params: P, output: Record<string, unknown>) => number
|
||||
): ToolHostingConfig<P> {
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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<LeadMagicProfileSearchParams>((_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<string, unknown> = { 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<string, string>).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 },
|
||||
},
|
||||
}
|
||||
@@ -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<LeadMagicProfileToEmailParams>((_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<string, string>).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 },
|
||||
},
|
||||
}
|
||||
@@ -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<LeadMagicRoleFinderParams, LeadMagicRoleFinderResponse> = {
|
||||
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<LeadMagicRoleFinderParams>((_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<string, string> = { 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<string, string>).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 },
|
||||
},
|
||||
}
|
||||
@@ -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<string, OutputProperty>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<string, string> | 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
|
||||
@@ -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<LeadMagicValidateEmailParams>((_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<string, string>).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 },
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
@@ -1537,6 +1546,7 @@ import {
|
||||
iamRemoveUserFromGroupTool,
|
||||
iamSimulatePrincipalPolicyTool,
|
||||
} from '@/tools/iam'
|
||||
import { icypeasFindEmailTool, icypeasVerifyEmailTool } from '@/tools/icypeas'
|
||||
import {
|
||||
identityCenterCheckAssignmentDeletionStatusTool,
|
||||
identityCenterCheckAssignmentStatusTool,
|
||||
@@ -1834,6 +1844,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,
|
||||
@@ -6526,6 +6547,25 @@ export const tools: Record<string, ToolConfig> = {
|
||||
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,
|
||||
|
||||
@@ -30,6 +30,11 @@ export type BYOKProviderId =
|
||||
| 'zerobounce'
|
||||
| 'neverbounce'
|
||||
| 'millionverifier'
|
||||
| 'datagma'
|
||||
| 'dropcontact'
|
||||
| 'leadmagic'
|
||||
| 'icypeas'
|
||||
| 'enrow'
|
||||
|
||||
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user