Skip to content

Commit 9f42316

Browse files
feat(enrichments): add Findymail, Prospeo, Wiza to work-email waterfall
1 parent 9a46a5a commit 9f42316

2 files changed

Lines changed: 123 additions & 2 deletions

File tree

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import type { EnrichmentProvider } from '@/enrichments/types'
6+
import { workEmailEnrichment } from '@/enrichments/work-email/work-email'
7+
8+
function provider(id: string): EnrichmentProvider {
9+
const p = workEmailEnrichment.providers.find((x) => x.id === id)
10+
if (!p) throw new Error(`Provider ${id} not found in work-email cascade`)
11+
return p
12+
}
13+
14+
const inputs = { fullName: 'John Doe', companyDomain: 'https://www.acme.com/careers' }
15+
16+
describe('work-email enrichment cascade', () => {
17+
it('chains the five hosted providers in waterfall order', () => {
18+
expect(workEmailEnrichment.providers.map((p) => p.id)).toEqual([
19+
'hunter',
20+
'findymail',
21+
'prospeo',
22+
'wiza',
23+
'pdl',
24+
])
25+
})
26+
27+
describe('findymail', () => {
28+
const p = provider('findymail')
29+
it('maps name + normalized domain and extracts contact.email', () => {
30+
expect(p.toolId).toBe('findymail_find_email_from_name')
31+
expect(p.buildParams(inputs)).toEqual({ name: 'John Doe', domain: 'acme.com' })
32+
expect(p.mapOutput({ contact: { email: 'j@acme.com' } })).toEqual({ email: 'j@acme.com' })
33+
expect(p.mapOutput({ contact: null })).toBeNull()
34+
})
35+
it('skips when name or domain is missing', () => {
36+
expect(p.buildParams({ fullName: '', companyDomain: 'acme.com' })).toBeNull()
37+
expect(p.buildParams({ fullName: 'John Doe', companyDomain: '' })).toBeNull()
38+
})
39+
})
40+
41+
describe('prospeo', () => {
42+
const p = provider('prospeo')
43+
it('maps full_name + company_website and extracts person.email.email', () => {
44+
expect(p.toolId).toBe('prospeo_enrich_person')
45+
expect(p.buildParams(inputs)).toEqual({ full_name: 'John Doe', company_website: 'acme.com' })
46+
expect(
47+
p.mapOutput({ person: { email: { email: 'j@acme.com', status: 'VERIFIED' } } })
48+
).toEqual({
49+
email: 'j@acme.com',
50+
})
51+
expect(p.mapOutput({ free_enrichment: true, person: null })).toBeNull()
52+
})
53+
})
54+
55+
describe('wiza', () => {
56+
const p = provider('wiza')
57+
it('reveals email-only (partial) and maps output.email', () => {
58+
expect(p.toolId).toBe('wiza_individual_reveal')
59+
expect(p.buildParams(inputs)).toEqual({
60+
full_name: 'John Doe',
61+
domain: 'acme.com',
62+
enrichment_level: 'partial',
63+
})
64+
expect(p.mapOutput({ email: 'j@acme.com', email_status: 'valid' })).toEqual({
65+
email: 'j@acme.com',
66+
})
67+
expect(p.mapOutput({ email: null })).toBeNull()
68+
})
69+
})
70+
})

apps/sim/enrichments/work-email/work-email.ts

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ import type { EnrichmentConfig } from '@/enrichments/types'
55

66
/**
77
* Work Email enrichment. Finds a person's work email from their full name and
8-
* company domain, trying Hunter first (deterministic finder) then People Data
9-
* Labs (record match) as a fallback.
8+
* company domain via a provider waterfall: deterministic finders first (Hunter,
9+
* Findymail), then enrichment/reveal providers (Prospeo, Wiza), then People Data
10+
* Labs as a broad record-match fallback. The first provider to return an email
11+
* wins; each provider supports hosted keys so the cascade runs without BYOK.
1012
*/
1113
export const workEmailEnrichment: EnrichmentConfig = {
1214
id: 'work-email',
@@ -34,6 +36,55 @@ export const workEmailEnrichment: EnrichmentConfig = {
3436
return email ? { email } : null
3537
},
3638
}),
39+
toolProvider({
40+
id: 'findymail',
41+
label: 'Findymail',
42+
toolId: 'findymail_find_email_from_name',
43+
buildParams: (inputs) => {
44+
const name = str(inputs.fullName)
45+
const domain = normalizeDomain(inputs.companyDomain)
46+
if (!name || !domain) return null
47+
return { name, domain }
48+
},
49+
mapOutput: (output) => {
50+
const contact = output.contact as Record<string, unknown> | null
51+
const email = str(contact?.email)
52+
return email ? { email } : null
53+
},
54+
}),
55+
toolProvider({
56+
id: 'prospeo',
57+
label: 'Prospeo',
58+
toolId: 'prospeo_enrich_person',
59+
buildParams: (inputs) => {
60+
const fullName = str(inputs.fullName)
61+
const companyWebsite = normalizeDomain(inputs.companyDomain)
62+
if (!fullName || !companyWebsite) return null
63+
return { full_name: fullName, company_website: companyWebsite }
64+
},
65+
mapOutput: (output) => {
66+
const person = output.person as Record<string, unknown> | undefined
67+
const emailObj = person?.email as Record<string, unknown> | undefined
68+
const email = str(emailObj?.email)
69+
return email ? { email } : null
70+
},
71+
}),
72+
toolProvider({
73+
id: 'wiza',
74+
label: 'Wiza',
75+
toolId: 'wiza_individual_reveal',
76+
buildParams: (inputs) => {
77+
const fullName = str(inputs.fullName)
78+
const domain = normalizeDomain(inputs.companyDomain)
79+
if (!fullName || !domain) return null
80+
// 'partial' reveals the email only (2 credits); avoids phone charges.
81+
return { full_name: fullName, domain, enrichment_level: 'partial' }
82+
},
83+
mapOutput: (output) => {
84+
const email = str(output.email)
85+
return email ? { email } : null
86+
},
87+
}),
3788
toolProvider({
3889
id: 'pdl',
3990
label: 'People Data Labs',

0 commit comments

Comments
 (0)