chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -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', required: 'website' })
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()
})
})
})
@@ -0,0 +1,50 @@
import { Globe } from 'lucide-react'
import { normalizeDomain, str, toolProvider } from '@/enrichments/providers'
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, falling back to Datagma's company enrich.
*/
export const companyDomainEnrichment: EnrichmentConfig = {
id: 'company-domain',
name: 'Company Domain',
description: "Find a company's website domain from its name.",
icon: Globe,
inputs: [{ id: 'companyName', name: 'Company name', type: 'string', required: true }],
outputs: [{ id: 'domain', name: 'domain', type: 'string' }],
providers: [
toolProvider({
id: 'pdl',
label: 'People Data Labs',
toolId: 'pdl_company_enrich',
buildParams: (inputs) => {
const name = str(inputs.companyName)
if (!name) return null
// `required` makes PDL 404 (free) when the match has no website,
// instead of charging a credit for a match we'd discard as a no-match.
return { name, required: 'website' }
},
mapOutput: (output) => {
const company = output.company as Record<string, unknown> | undefined
const domain = normalizeDomain(company?.website)
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 @@
export { companyDomainEnrichment } from './company-domain'
@@ -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({})
})
})
})
@@ -0,0 +1,94 @@
import { filterUndefined } from '@sim/utils/object'
import { Building2 } from 'lucide-react'
import { normalizeDomain, str, toolProvider } from '@/enrichments/providers'
import type { EnrichmentConfig } from '@/enrichments/types'
/**
* Company Info enrichment. Looks up a company by domain, trying Hunter first
* (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',
name: 'Company Info',
description: "Look up a company's size and description from its domain.",
icon: Building2,
inputs: [{ id: 'domain', name: 'Company domain', type: 'string', required: true }],
outputs: [
{ id: 'employeeCount', name: 'employee count', type: 'string' },
{ id: 'description', name: 'description', type: 'string' },
],
providers: [
toolProvider({
id: 'hunter',
label: 'Hunter',
toolId: 'hunter_companies_find',
buildParams: (inputs) => {
const domain = normalizeDomain(inputs.domain)
if (!domain) return null
return { domain }
},
mapOutput: (output) => {
return filterUndefined({
employeeCount: str(output.size) || undefined,
description: str(output.description) || undefined,
})
},
}),
toolProvider({
id: 'pdl',
label: 'People Data Labs',
toolId: 'pdl_company_enrich',
buildParams: (inputs) => {
const website = normalizeDomain(inputs.domain)
if (!website) return null
// `required` makes PDL 404 (free) when neither field we extract is
// present, instead of charging a credit for a match we'd discard.
return { website, required: 'employee_count OR summary' }
},
mapOutput: (output) => {
const company = output.company as Record<string, unknown> | undefined
return filterUndefined({
employeeCount: str(company?.employee_count) || undefined,
description: str(company?.summary) || undefined,
})
},
}),
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 @@
export { companyInfoEnrichment } from './company-info'
@@ -0,0 +1,59 @@
/**
* @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',
'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('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()
})
})
})
@@ -0,0 +1,89 @@
import { ShieldCheck } from '@sim/emcn/icons'
import { str, toolProvider } from '@/enrichments/providers'
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, 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',
name: 'Email Verification',
description: "Check an email address's deliverability and risk status.",
icon: ShieldCheck,
inputs: [{ id: 'email', name: 'Email', type: 'string', required: true }],
outputs: [
{ id: 'status', name: 'status', type: 'string' },
{ id: 'deliverable', name: 'deliverable', type: 'boolean' },
],
providers: [
toolProvider({
id: 'zerobounce',
label: 'ZeroBounce',
toolId: 'zerobounce_verify_email',
buildParams: (inputs) => {
const email = str(inputs.email)
if (!email) return null
return { email }
},
mapOutput: (output) => {
const status = str(output.status)
// Fall through to the next verifier when the verdict is missing or inconclusive.
if (!status || status === 'unknown') return null
return { status, deliverable: output.deliverable === true }
},
}),
toolProvider({
id: 'neverbounce',
label: 'NeverBounce',
toolId: 'neverbounce_verify_email',
buildParams: (inputs) => {
const email = str(inputs.email)
if (!email) return null
return { email }
},
mapOutput: (output) => {
const status = str(output.status)
if (!status || status === 'unknown') return null
return { status, deliverable: output.deliverable === true }
},
}),
toolProvider({
id: 'millionverifier',
label: 'MillionVerifier',
toolId: 'millionverifier_verify_email',
buildParams: (inputs) => {
const email = str(inputs.email)
if (!email) return null
return { email }
},
mapOutput: (output) => {
const status = str(output.status)
if (!status || status === 'unknown') return null
return { status, deliverable: output.deliverable === true }
},
}),
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
},
}),
],
}
@@ -0,0 +1 @@
export { emailVerificationEnrichment } from './email-verification'
+9
View File
@@ -0,0 +1,9 @@
export { ALL_ENRICHMENTS, ENRICHMENT_REGISTRY, getEnrichment } from './registry'
export type {
EnrichmentConfig,
EnrichmentInputField,
EnrichmentOutputField,
EnrichmentProvider,
EnrichmentRegistry,
EnrichmentRunContext,
} from './types'
@@ -0,0 +1 @@
export { phoneNumberEnrichment } from './phone-number'
@@ -0,0 +1,104 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { phoneNumberEnrichment } from '@/enrichments/phone-number/phone-number'
import type { EnrichmentProvider } from '@/enrichments/types'
function provider(id: string): EnrichmentProvider {
const p = phoneNumberEnrichment.providers.find((x) => x.id === id)
if (!p) throw new Error(`Provider ${id} not found in phone-number cascade`)
return p
}
const nameDomain = { fullName: 'John Doe', companyDomain: 'https://www.acme.com/careers' }
const linkedinOnly = { fullName: 'John Doe', linkedinUrl: 'https://linkedin.com/in/johndoe' }
describe('phone-number enrichment cascade', () => {
it('chains PDL then the phone-capable hosted providers', () => {
expect(phoneNumberEnrichment.providers.map((p) => p.id)).toEqual([
'pdl',
'wiza',
'findymail',
'prospeo',
'leadmagic',
'datagma',
])
})
describe('wiza (opportunistic)', () => {
const p = provider('wiza')
it('reveals phone, using name+domain or LinkedIn profile_url', () => {
expect(p.toolId).toBe('wiza_individual_reveal')
expect(p.buildParams(nameDomain)).toEqual({
full_name: 'John Doe',
domain: 'acme.com',
enrichment_level: 'phone',
})
expect(p.buildParams(linkedinOnly)).toEqual({
full_name: 'John Doe',
profile_url: 'https://linkedin.com/in/johndoe',
enrichment_level: 'phone',
})
expect(p.buildParams({ fullName: 'John Doe' })).toBeNull()
expect(p.mapOutput({ mobile_phone: '+1555', phones: [] })).toEqual({ phone: '+1555' })
expect(p.mapOutput({ phones: [{ number: '+1777' }] })).toEqual({ phone: '+1777' })
})
})
describe('findymail', () => {
const p = provider('findymail')
it('keys off the LinkedIn URL and skips without one', () => {
expect(p.toolId).toBe('findymail_find_phone')
expect(p.buildParams(linkedinOnly)).toEqual({
linkedin_url: 'https://linkedin.com/in/johndoe',
})
expect(p.buildParams(nameDomain)).toBeNull()
expect(p.mapOutput({ phone: '+1555' })).toEqual({ phone: '+1555' })
expect(p.mapOutput({ phone: null })).toBeNull()
})
})
describe('prospeo (opportunistic)', () => {
const p = provider('prospeo')
it('requests mobile enrichment via name+domain or LinkedIn', () => {
expect(p.toolId).toBe('prospeo_enrich_person')
expect(p.buildParams(nameDomain)).toEqual({
full_name: 'John Doe',
company_website: 'acme.com',
enrich_mobile: true,
})
expect(p.buildParams(linkedinOnly)).toEqual({
full_name: 'John Doe',
linkedin_url: 'https://linkedin.com/in/johndoe',
enrich_mobile: true,
})
expect(p.buildParams({ fullName: 'John Doe' })).toBeNull()
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()
})
})
})
@@ -0,0 +1,144 @@
import { filterUndefined } from '@sim/utils/object'
import { Phone } from 'lucide-react'
import { firstNonEmpty, normalizeDomain, str, toolProvider } from '@/enrichments/providers'
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 → 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',
name: 'Phone Number',
description: "Find a contact's phone number from their name, company, or LinkedIn URL.",
icon: Phone,
inputs: [
{ id: 'fullName', name: 'Full name', type: 'string', required: true },
{ id: 'companyDomain', name: 'Company domain', type: 'string' },
{ id: 'linkedinUrl', name: 'LinkedIn URL', type: 'string' },
],
outputs: [{ id: 'phone', name: 'phone', type: 'string' }],
providers: [
toolProvider({
id: 'pdl',
label: 'People Data Labs',
toolId: 'pdl_person_enrich',
buildParams: (inputs) => {
const name = str(inputs.fullName)
if (!name) return null
// `required` makes PDL 404 (free) when the profile has no phone,
// instead of charging a credit for a match we'd discard as a no-match.
return filterUndefined({
name,
company: normalizeDomain(inputs.companyDomain) || undefined,
min_likelihood: 6,
required: 'phone_numbers OR mobile_phone',
})
},
mapOutput: (output) => {
const person = output.person as Record<string, unknown> | undefined
const phone = firstNonEmpty(person?.phone_numbers) ?? str(person?.mobile_phone)
return phone ? { phone } : null
},
}),
toolProvider({
id: 'wiza',
label: 'Wiza',
toolId: 'wiza_individual_reveal',
buildParams: (inputs) => {
const linkedin = str(inputs.linkedinUrl)
const fullName = str(inputs.fullName)
const domain = normalizeDomain(inputs.companyDomain)
// Needs a LinkedIn URL or a name+domain pair; skip otherwise.
if (!linkedin && !(fullName && domain)) return null
// 'phone' reveals the mobile number (5 credits). Prefer LinkedIn when present.
return filterUndefined({
profile_url: linkedin || undefined,
full_name: fullName || undefined,
domain: domain || undefined,
enrichment_level: 'phone',
})
},
mapOutput: (output) => {
const phones = Array.isArray(output.phones)
? (output.phones as Record<string, unknown>[])
: []
const phone = str(output.mobile_phone) || str(output.phone_number) || str(phones[0]?.number)
return phone ? { phone } : null
},
}),
toolProvider({
id: 'findymail',
label: 'Findymail',
toolId: 'findymail_find_phone',
buildParams: (inputs) => {
// Findymail's phone finder keys off a LinkedIn URL only.
const linkedin = str(inputs.linkedinUrl)
if (!linkedin) return null
return { linkedin_url: linkedin }
},
mapOutput: (output) => {
const phone = str(output.phone)
return phone ? { phone } : null
},
}),
toolProvider({
id: 'prospeo',
label: 'Prospeo',
toolId: 'prospeo_enrich_person',
buildParams: (inputs) => {
const linkedin = str(inputs.linkedinUrl)
const fullName = str(inputs.fullName)
const companyWebsite = normalizeDomain(inputs.companyDomain)
if (!linkedin && !(fullName && companyWebsite)) return null
return filterUndefined({
linkedin_url: linkedin || undefined,
full_name: fullName || undefined,
company_website: companyWebsite || undefined,
enrich_mobile: true,
})
},
mapOutput: (output) => {
const person = output.person as Record<string, unknown> | undefined
const mobile = person?.mobile as Record<string, unknown> | undefined
const phone = str(mobile?.mobile)
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
},
}),
],
}
+68
View File
@@ -0,0 +1,68 @@
import type {
EnrichmentInputField,
EnrichmentOutputField,
EnrichmentProvider,
} from '@/enrichments/types'
/**
* Narrow union of the field types enrichments declare. Assignable to both the
* tool `OutputType` and the block `ParamType` unions, so a single mapping
* function feeds both sides without per-call casts.
*/
export type EnrichmentFieldType = 'string' | 'number' | 'boolean' | 'json'
/** Maps an enrichment input/output column type to a block/tool field type. */
export function mapFieldType(
type: EnrichmentInputField['type'] | EnrichmentOutputField['type']
): EnrichmentFieldType {
if (type === 'number') return 'number'
if (type === 'boolean') return 'boolean'
if (type === 'json') return 'json'
return 'string'
}
/** Coerces an unknown input value to a trimmed string (`''` when nullish). */
export function str(value: unknown): string {
return String(value ?? '').trim()
}
/** Strips protocol / path / leading `www.` from a domain-ish input. */
export function normalizeDomain(value: unknown): string {
return str(value)
.toLowerCase()
.replace(/^https?:\/\//, '')
.replace(/^www\./, '')
.replace(/\/.*$/, '')
}
/** Returns the first non-empty string in an array (or `undefined`). */
export function firstNonEmpty(value: unknown): string | undefined {
if (!Array.isArray(value)) return undefined
for (const item of value) {
const s = str(item)
if (s) return s
}
return undefined
}
/**
* Splits a full name into first / last for providers whose API requires both
* (e.g. Hunter). Returns `null` when the name has fewer than two parts, so the
* provider falls through to one that accepts a single name string.
*/
export function splitName(fullName: unknown): { firstName: string; lastName: string } | null {
const parts = str(fullName).split(/\s+/).filter(Boolean)
if (parts.length < 2) return null
return { firstName: parts[0], lastName: parts.slice(1).join(' ') }
}
/**
* Declares a tool-backed enrichment provider as plain data. Keeping this free of
* any `@/tools` reference (the cascade runner does the `executeTool` call) means
* the enrichment catalog stays client-safe — the table UI imports it only for
* metadata. Workspace scope and BYOK / hosted-key injection are handled by the
* runner when it executes `toolId`.
*/
export function toolProvider(provider: EnrichmentProvider): EnrichmentProvider {
return provider
}
+21
View File
@@ -0,0 +1,21 @@
import { companyDomainEnrichment } from '@/enrichments/company-domain'
import { companyInfoEnrichment } from '@/enrichments/company-info'
import { emailVerificationEnrichment } from '@/enrichments/email-verification'
import { phoneNumberEnrichment } from '@/enrichments/phone-number'
import type { EnrichmentConfig, EnrichmentRegistry } from '@/enrichments/types'
import { workEmailEnrichment } from '@/enrichments/work-email'
export const ENRICHMENT_REGISTRY: EnrichmentRegistry = {
[workEmailEnrichment.id]: workEmailEnrichment,
[emailVerificationEnrichment.id]: emailVerificationEnrichment,
[phoneNumberEnrichment.id]: phoneNumberEnrichment,
[companyDomainEnrichment.id]: companyDomainEnrichment,
[companyInfoEnrichment.id]: companyInfoEnrichment,
}
/** All enrichments, in catalog order. */
export const ALL_ENRICHMENTS: EnrichmentConfig[] = Object.values(ENRICHMENT_REGISTRY)
export function getEnrichment(id: string | undefined): EnrichmentConfig | undefined {
return id ? ENRICHMENT_REGISTRY[id] : undefined
}
+155
View File
@@ -0,0 +1,155 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockExecuteTool } = vi.hoisted(() => ({ mockExecuteTool: vi.fn() }))
vi.mock('@/tools', () => ({ executeTool: mockExecuteTool }))
import { runEnrichment, skippedEnrichmentDetail } from '@/enrichments/run'
import type { EnrichmentConfig, EnrichmentProvider } from '@/enrichments/types'
const ICON = (() => null) as unknown as EnrichmentConfig['icon']
function prov(
id: string,
opts: {
build?: (inputs: Record<string, unknown>) => Record<string, unknown> | null
map?: (output: Record<string, unknown>) => Record<string, unknown> | null
} = {}
): EnrichmentProvider {
return {
id,
label: id.toUpperCase(),
toolId: `tool_${id}`,
buildParams: opts.build ?? (() => ({ q: 'x' })),
mapOutput: opts.map ?? ((o) => (o.email ? { email: o.email } : null)),
}
}
function config(providers: EnrichmentProvider[]): EnrichmentConfig {
return {
id: 'test',
name: 'Test',
description: '',
icon: ICON,
inputs: [],
outputs: [],
providers,
}
}
const ctx = { workspaceId: 'ws-1' }
beforeEach(() => {
mockExecuteTool.mockReset()
})
describe('runEnrichment cascade detail', () => {
it('records the first match and stops the cascade', async () => {
mockExecuteTool.mockImplementation((toolId: string) => {
if (toolId === 'tool_a') return { success: false, output: { status: 404 } }
if (toolId === 'tool_b')
return { success: true, output: { email: 'j@acme.com', cost: { total: 0.05 } } }
throw new Error('tool_c should never run after a match')
})
const outcome = await runEnrichment(config([prov('a'), prov('b'), prov('c')]), {}, ctx)
expect(outcome.result).toEqual({ email: 'j@acme.com' })
expect(outcome.cost).toBe(0.05)
expect(outcome.error).toBeNull()
expect(outcome.provider).toBe('B')
expect(outcome.detail.matchedProvider).toBe('b')
expect(outcome.detail.totalCost).toBe(0.05)
// The full cascade is recorded; the provider after the match is `not_run`.
expect(outcome.detail.providers.map((p) => p.id)).toEqual(['a', 'b', 'c'])
expect(outcome.detail.providers.map((p) => p.status)).toEqual([
'no_match',
'matched',
'not_run',
])
expect(outcome.detail.providers[1]?.cost).toBe(0.05)
expect(outcome.detail.providers.every((p) => typeof p.durationMs === 'number')).toBe(true)
// The tool is never called for the matched-past provider.
expect(mockExecuteTool).toHaveBeenCalledTimes(2)
})
it('marks providers with insufficient inputs as skipped without calling the tool', async () => {
mockExecuteTool.mockImplementation(() => ({
success: true,
output: { email: 'j@acme.com' },
}))
const outcome = await runEnrichment(
config([prov('a', { build: () => null }), prov('b')]),
{},
ctx
)
expect(outcome.detail.providers[0]).toMatchObject({ id: 'a', status: 'skipped', durationMs: 0 })
expect(outcome.detail.providers[1]?.status).toBe('matched')
// Only provider b actually called the tool.
expect(mockExecuteTool).toHaveBeenCalledTimes(1)
})
it('sets error only when every provider that ran errored', async () => {
mockExecuteTool.mockImplementation(() => ({ success: false, output: { status: 500 } }))
const outcome = await runEnrichment(config([prov('a'), prov('b')]), {}, ctx)
expect(outcome.result).toEqual({})
expect(outcome.error).not.toBeNull()
expect(outcome.provider).toBeNull()
expect(outcome.detail.matchedProvider).toBeNull()
expect(outcome.detail.providers.map((p) => p.status)).toEqual(['error', 'error'])
expect(outcome.detail.providers.every((p) => p.error)).toBe(true)
})
it('treats a clean miss (ran, empty result) as no_match with no error', async () => {
mockExecuteTool.mockImplementation(() => ({ success: true, output: {} }))
const outcome = await runEnrichment(config([prov('a')]), {}, ctx)
expect(outcome.result).toEqual({})
expect(outcome.error).toBeNull()
expect(outcome.detail.providers.map((p) => p.status)).toEqual(['no_match'])
})
it('skippedEnrichmentDetail marks every provider skipped without running', () => {
const detail = skippedEnrichmentDetail(config([prov('a'), prov('b')]))
expect(detail.matchedProvider).toBeNull()
expect(detail.totalCost).toBe(0)
expect(detail.providers.map((p) => p.status)).toEqual(['skipped', 'skipped'])
expect(mockExecuteTool).not.toHaveBeenCalled()
})
it('marks unattempted providers not_run when the signal is already aborted', async () => {
const controller = new AbortController()
controller.abort()
const outcome = await runEnrichment(
config([prov('a'), prov('b')]),
{},
{
...ctx,
signal: controller.signal,
}
)
expect(mockExecuteTool).not.toHaveBeenCalled()
expect(outcome.detail.aborted).toBe(true)
expect(outcome.detail.providers.map((p) => p.status)).toEqual(['not_run', 'not_run'])
})
it('does not error when some providers no-match and only some error', async () => {
mockExecuteTool.mockImplementation((toolId: string) => {
if (toolId === 'tool_a') return { success: false, output: { status: 500 } }
return { success: false, output: { status: 404 } }
})
const outcome = await runEnrichment(config([prov('a'), prov('b')]), {}, ctx)
expect(outcome.error).toBeNull()
expect(outcome.detail.providers.map((p) => p.status)).toEqual(['error', 'no_match'])
})
})
+223
View File
@@ -0,0 +1,223 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import type { EnrichmentProviderOutcome, EnrichmentRunDetail } from '@/lib/table/types'
import type { EnrichmentConfig, EnrichmentRunContext } from '@/enrichments/types'
import { executeTool } from '@/tools'
const logger = createLogger('Enrichments')
/** Outcome of running an enrichment's provider cascade for one row. */
export interface EnrichmentRunOutcome {
/** Mapped output values from the winning provider, or `{}` when none hit. */
result: Record<string, unknown>
/** Total hosted-key cost (USD) across providers that ran; `0` for BYOK / free. */
cost: number
/**
* Set only when every provider that actually ran errored (none produced a
* clean result or a clean miss). Lets the caller mark the cell errored instead
* of blanking it — a genuine "no match" still leaves this `null`.
*/
error: string | null
/** Label of the provider whose result was returned, or `null` on no match. */
provider: string | null
/** Per-provider cascade breakdown + timing for the enrichment details panel. */
detail: EnrichmentRunDetail
}
/**
* Detail for a terminal cell that recorded no provider attempt — missing
* required inputs, or cancelled before any provider ran. Every provider is
* marked `skipped` so the details panel stays informative (shows the configured
* cascade) instead of empty.
*/
export function skippedEnrichmentDetail(
enrichment: EnrichmentConfig,
opts: { aborted?: boolean } = {}
): EnrichmentRunDetail {
const now = new Date().toISOString()
return {
startedAt: now,
completedAt: now,
durationMs: 0,
totalCost: 0,
matchedProvider: null,
aborted: opts.aborted ?? false,
providers: enrichment.providers.map((provider) => ({
id: provider.id,
label: provider.label,
toolId: provider.toolId,
status: 'skipped' as const,
cost: 0,
durationMs: 0,
error: null,
})),
}
}
/** True when at least one output value in the result is non-empty. */
function hasResult(result: Record<string, unknown>): boolean {
return Object.values(result).some((v) => v !== undefined && v !== null && v !== '')
}
/** Reads the hosted-key cost `executeTool` merges into a successful output. */
function readCost(output: Record<string, unknown>): number {
const total = (output.cost as { total?: unknown } | undefined)?.total
return typeof total === 'number' && Number.isFinite(total) && total > 0 ? total : 0
}
/**
* Runs an enrichment's provider cascade for one row. Tries providers in order;
* the first that returns a non-empty result wins and is returned. A provider is
* skipped when its `buildParams` returns `null` (insufficient inputs); a tool
* failure or empty mapped result falls through to the next. When every provider
* that ran errored, `error` is set so the caller can mark the cell errored; a
* clean miss leaves `error: null` (blank cell). Hosted-key cost is accumulated
* across providers for the caller to bill.
*
* Server-only: imports `executeTool`, which pulls in DB / mailer code. Only the
* background cell executor imports this module (dynamically).
*/
export async function runEnrichment(
enrichment: EnrichmentConfig,
inputs: Record<string, unknown>,
ctx: EnrichmentRunContext
): Promise<EnrichmentRunOutcome> {
let cost = 0
let ranCount = 0
let errorCount = 0
let lastError: string | null = null
let matchedProvider: string | null = null
let winner: { result: Record<string, unknown>; label: string } | null = null
const providers: EnrichmentProviderOutcome[] = []
const startedAt = Date.now()
for (let i = 0; i < enrichment.providers.length; i++) {
const provider = enrichment.providers[i]
if (ctx.signal?.aborted) break
const params = provider.buildParams(inputs)
if (!params) {
providers.push({
id: provider.id,
label: provider.label,
toolId: provider.toolId,
status: 'skipped',
cost: 0,
durationMs: 0,
error: null,
})
continue
}
ranCount++
const providerStart = Date.now()
try {
const response = await executeTool(
provider.toolId,
{ ...params, _context: { workspaceId: ctx.workspaceId } },
{ signal: ctx.signal }
)
if (!response.success) {
// A 404 means the provider simply has no record for these inputs — a
// clean no-match, not an infra failure. Fall through to the next
// provider without counting it as an error (so the cell shows "Not
// found" rather than an error). Other statuses (auth, rate-limit, 5xx)
// are real errors and propagate.
const status = (response.output as { status?: unknown } | undefined)?.status
if (status === 404) {
providers.push({
id: provider.id,
label: provider.label,
toolId: provider.toolId,
status: 'no_match',
cost: 0,
durationMs: Date.now() - providerStart,
error: null,
})
continue
}
throw new Error(response.error ?? `${provider.toolId} failed`)
}
const providerCost = readCost(response.output)
cost += providerCost
const result = provider.mapOutput(response.output)
if (result && hasResult(result)) {
providers.push({
id: provider.id,
label: provider.label,
toolId: provider.toolId,
status: 'matched',
cost: providerCost,
durationMs: Date.now() - providerStart,
error: null,
})
matchedProvider = provider.id
winner = { result, label: provider.label }
logger.info('Enrichment hit', { enrichmentId: enrichment.id, provider: provider.id })
break
}
// Ran cleanly but mapped to nothing — a no-match, fall through to the next.
providers.push({
id: provider.id,
label: provider.label,
toolId: provider.toolId,
status: 'no_match',
cost: providerCost,
durationMs: Date.now() - providerStart,
error: null,
})
} catch (err) {
errorCount++
lastError = getErrorMessage(err)
providers.push({
id: provider.id,
label: provider.label,
toolId: provider.toolId,
status: 'error',
cost: 0,
durationMs: Date.now() - providerStart,
error: lastError,
})
logger.warn('Enrichment provider failed; trying next', {
enrichmentId: enrichment.id,
provider: provider.id,
error: lastError,
})
}
}
// Any provider not represented yet never ran — the cascade short-circuited on
// a match or aborted mid-run. Record them as `not_run` (in registry order) so
// the panel always shows the full configured cascade.
const seen = new Set(providers.map((p) => p.id))
for (const provider of enrichment.providers) {
if (seen.has(provider.id)) continue
providers.push({
id: provider.id,
label: provider.label,
toolId: provider.toolId,
status: 'not_run',
cost: 0,
durationMs: 0,
error: null,
})
}
const completedAt = Date.now()
const detail: EnrichmentRunDetail = {
startedAt: new Date(startedAt).toISOString(),
completedAt: new Date(completedAt).toISOString(),
durationMs: completedAt - startedAt,
totalCost: cost,
matchedProvider,
aborted: Boolean(ctx.signal?.aborted),
providers,
}
if (winner) {
return { result: winner.result, cost, error: null, provider: winner.label, detail }
}
// No provider hit. Surface an error only when every provider that ran errored
// (infra/auth/rate-limit) — a clean miss returns a blank result instead.
const error = ranCount > 0 && errorCount === ranCount ? lastError : null
return { result: {}, cost, error, provider: null, detail }
}
+79
View File
@@ -0,0 +1,79 @@
import type React from 'react'
import type { ColumnDefinition } from '@/lib/table'
/** One per-row input an enrichment needs. Mapped to a table column by the user. */
export interface EnrichmentInputField {
/** Stable key passed into `enrich()` (`inputs[id]`). */
id: string
/** Human label shown in the config panel. */
name: string
type: 'string' | 'number' | 'boolean'
required?: boolean
description?: string
}
/** One value an enrichment produces. Becomes a table column. */
export interface EnrichmentOutputField {
/** Key the value is returned under from a provider's `run()` (`result[id]`). */
id: string
/** Default column name. */
name: string
type: ColumnDefinition['type']
}
/**
* Execution context for an enrichment run (runs server-side). `tableId`/`rowId`
* are present for the table per-row path but optional — the workflow block path
* (`/api/tools/enrichment/run`) has no table/row and passes only `workspaceId`.
*/
export interface EnrichmentRunContext {
tableId?: string
rowId?: string
workspaceId: string
signal?: AbortSignal
}
/**
* One data source an enrichment can try, described as plain data so the catalog
* (which the table UI imports for metadata) never pulls in server-only tool
* code. Providers are attempted in declared order (a fallback cascade); the
* cascade runner (`run.ts`, server-only) calls the tool and the first provider
* to return a non-empty result fills the cell.
*/
export interface EnrichmentProvider {
/** Stable id for logs, e.g. `'hunter'`, `'pdl'`. */
id: string
/** Human label, e.g. `'Hunter'`, `'People Data Labs'`. */
label: string
/** Tool executed via `executeTool` (in the server-only runner). */
toolId: string
/**
* Maps enrichment inputs to tool params, or `null` when there aren't enough
* inputs to run this provider (cascade falls through to the next).
*/
buildParams: (inputs: Record<string, unknown>) => Record<string, unknown> | null
/**
* Maps the tool's output to `{ [outputId]: value }`, or `null` for no result.
* An empty/`null` result falls through to the next provider.
*/
mapOutput: (output: Record<string, unknown>) => Record<string, unknown> | null
}
/**
* A code-defined enrichment. Runs directly per table row (no workflow): the
* table's per-cell executor runs the provider cascade with the mapped inputs
* and writes each returned output value into its column.
*/
export interface EnrichmentConfig {
id: string
name: string
description: string
/** Shown in the catalog + (future) column header. */
icon: React.ComponentType<{ className?: string }>
inputs: EnrichmentInputField[]
outputs: EnrichmentOutputField[]
/** Data sources tried in order until one returns a non-empty result. */
providers: EnrichmentProvider[]
}
export type EnrichmentRegistry = Record<string, EnrichmentConfig>
+1
View File
@@ -0,0 +1 @@
export { workEmailEnrichment } from './work-email'
@@ -0,0 +1,150 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import type { EnrichmentProvider } from '@/enrichments/types'
import { workEmailEnrichment } from '@/enrichments/work-email/work-email'
function provider(id: string): EnrichmentProvider {
const p = workEmailEnrichment.providers.find((x) => x.id === id)
if (!p) throw new Error(`Provider ${id} not found in work-email cascade`)
return p
}
const nameDomain = { fullName: 'John Doe', companyDomain: 'https://www.acme.com/careers' }
const linkedinOnly = { fullName: 'John Doe', linkedinUrl: 'https://linkedin.com/in/johndoe' }
describe('work-email enrichment cascade', () => {
it('chains the hosted providers in waterfall order', () => {
expect(workEmailEnrichment.providers.map((p) => p.id)).toEqual([
'hunter',
'findymail',
'findymail-linkedin',
'prospeo',
'wiza',
'pdl',
'datagma',
'leadmagic',
'dropcontact',
'enrow',
])
})
describe('findymail (name)', () => {
const p = provider('findymail')
it('maps name + domain and extracts contact.email', () => {
expect(p.toolId).toBe('findymail_find_email_from_name')
expect(p.buildParams(nameDomain)).toEqual({ name: 'John Doe', domain: 'acme.com' })
expect(p.mapOutput({ contact: { email: 'j@acme.com' } })).toEqual({ email: 'j@acme.com' })
expect(p.buildParams(linkedinOnly)).toBeNull()
})
})
describe('findymail-linkedin', () => {
const p = provider('findymail-linkedin')
it('keys off the LinkedIn URL and skips without one', () => {
expect(p.toolId).toBe('findymail_find_email_from_linkedin')
expect(p.buildParams(linkedinOnly)).toEqual({
linkedin_url: 'https://linkedin.com/in/johndoe',
})
expect(p.buildParams(nameDomain)).toBeNull()
expect(p.mapOutput({ contact: { email: 'j@acme.com' } })).toEqual({ email: 'j@acme.com' })
})
})
describe('prospeo (opportunistic)', () => {
const p = provider('prospeo')
it('uses name+domain, or LinkedIn when present', () => {
expect(p.buildParams(nameDomain)).toEqual({
full_name: 'John Doe',
company_website: 'acme.com',
})
expect(p.buildParams(linkedinOnly)).toEqual({
full_name: 'John Doe',
linkedin_url: 'https://linkedin.com/in/johndoe',
})
expect(p.buildParams({ fullName: 'John Doe' })).toBeNull()
expect(p.mapOutput({ person: { email: { email: 'j@acme.com' } } })).toEqual({
email: 'j@acme.com',
})
})
})
describe('wiza (opportunistic)', () => {
const p = provider('wiza')
it('reveals email-only (partial), preferring LinkedIn profile_url', () => {
expect(p.buildParams(nameDomain)).toEqual({
full_name: 'John Doe',
domain: 'acme.com',
enrichment_level: 'partial',
})
expect(p.buildParams(linkedinOnly)).toEqual({
full_name: 'John Doe',
profile_url: 'https://linkedin.com/in/johndoe',
enrichment_level: 'partial',
})
expect(p.buildParams({ fullName: 'John Doe' })).toBeNull()
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('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()
})
})
})
@@ -0,0 +1,212 @@
import { Mail } from '@sim/emcn/icons'
import { filterUndefined } from '@sim/utils/object'
import { normalizeDomain, splitName, str, toolProvider } from '@/enrichments/providers'
import type { EnrichmentConfig } from '@/enrichments/types'
/**
* Work Email enrichment. Finds a person's work email from a full name plus any
* 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, then Datagma, LeadMagic, Dropcontact, 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.
*/
export const workEmailEnrichment: EnrichmentConfig = {
id: 'work-email',
name: 'Work Email',
description: "Find a person's work email from their name, company, or LinkedIn URL.",
icon: Mail,
inputs: [
{ id: 'fullName', name: 'Full name', type: 'string', required: true },
{ id: 'companyDomain', name: 'Company domain', type: 'string' },
{ id: 'linkedinUrl', name: 'LinkedIn URL', type: 'string' },
],
outputs: [{ id: 'email', name: 'email', type: 'string' }],
providers: [
toolProvider({
id: 'hunter',
label: 'Hunter',
toolId: 'hunter_email_finder',
buildParams: (inputs) => {
const name = splitName(inputs.fullName)
const domain = normalizeDomain(inputs.companyDomain)
if (!name || !domain) return null
return { domain, first_name: name.firstName, last_name: name.lastName }
},
mapOutput: (output) => {
const email = str(output.email)
return email ? { email } : null
},
}),
toolProvider({
id: 'findymail',
label: 'Findymail',
toolId: 'findymail_find_email_from_name',
buildParams: (inputs) => {
const name = str(inputs.fullName)
const domain = normalizeDomain(inputs.companyDomain)
if (!name || !domain) return null
return { name, domain }
},
mapOutput: (output) => {
const contact = output.contact as Record<string, unknown> | null
const email = str(contact?.email)
return email ? { email } : null
},
}),
toolProvider({
id: 'findymail-linkedin',
label: 'Findymail (LinkedIn)',
toolId: 'findymail_find_email_from_linkedin',
buildParams: (inputs) => {
const linkedin = str(inputs.linkedinUrl)
if (!linkedin) return null
return { linkedin_url: linkedin }
},
mapOutput: (output) => {
const contact = output.contact as Record<string, unknown> | null
const email = str(contact?.email)
return email ? { email } : null
},
}),
toolProvider({
id: 'prospeo',
label: 'Prospeo',
toolId: 'prospeo_enrich_person',
buildParams: (inputs) => {
const linkedin = str(inputs.linkedinUrl)
const fullName = str(inputs.fullName)
const companyWebsite = normalizeDomain(inputs.companyDomain)
if (!linkedin && !(fullName && companyWebsite)) return null
return filterUndefined({
linkedin_url: linkedin || undefined,
full_name: fullName || undefined,
company_website: companyWebsite || undefined,
})
},
mapOutput: (output) => {
const person = output.person as Record<string, unknown> | undefined
const emailObj = person?.email as Record<string, unknown> | undefined
const email = str(emailObj?.email)
return email ? { email } : null
},
}),
toolProvider({
id: 'wiza',
label: 'Wiza',
toolId: 'wiza_individual_reveal',
buildParams: (inputs) => {
const linkedin = str(inputs.linkedinUrl)
const fullName = str(inputs.fullName)
const domain = normalizeDomain(inputs.companyDomain)
if (!linkedin && !(fullName && domain)) return null
// 'partial' reveals the email only (2 credits); avoids phone charges.
return filterUndefined({
profile_url: linkedin || undefined,
full_name: fullName || undefined,
domain: domain || undefined,
enrichment_level: 'partial',
})
},
mapOutput: (output) => {
const email = str(output.email)
return email ? { email } : null
},
}),
toolProvider({
id: 'pdl',
label: 'People Data Labs',
toolId: 'pdl_person_enrich',
buildParams: (inputs) => {
const name = str(inputs.fullName)
if (!name) return null
// `required` makes PDL 404 (free) when the profile has no work email,
// instead of charging a credit for a match we'd discard as a no-match.
return filterUndefined({
name,
company: normalizeDomain(inputs.companyDomain) || undefined,
min_likelihood: 6,
required: 'work_email',
})
},
mapOutput: (output) => {
const person = output.person as Record<string, unknown> | undefined
const email = str(person?.work_email)
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: '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
},
}),
],
}