Skip to content

Commit b378e88

Browse files
feat(enrichments): opportunistic identifiers + LinkedIn URL input across work-email & phone cascades
1 parent a33b6b3 commit b378e88

4 files changed

Lines changed: 159 additions & 63 deletions

File tree

apps/sim/enrichments/phone-number/phone-number.test.ts

Lines changed: 38 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,47 +11,68 @@ function provider(id: string): EnrichmentProvider {
1111
return p
1212
}
1313

14-
const inputs = { fullName: 'John Doe', companyDomain: 'https://www.acme.com/careers' }
14+
const nameDomain = { fullName: 'John Doe', companyDomain: 'https://www.acme.com/careers' }
15+
const linkedinOnly = { fullName: 'John Doe', linkedinUrl: 'https://linkedin.com/in/johndoe' }
1516

1617
describe('phone-number enrichment cascade', () => {
1718
it('chains PDL then the phone-capable hosted providers', () => {
18-
expect(phoneNumberEnrichment.providers.map((p) => p.id)).toEqual(['pdl', 'wiza', 'prospeo'])
19+
expect(phoneNumberEnrichment.providers.map((p) => p.id)).toEqual([
20+
'pdl',
21+
'wiza',
22+
'findymail',
23+
'prospeo',
24+
])
1925
})
2026

21-
describe('wiza', () => {
27+
describe('wiza (opportunistic)', () => {
2228
const p = provider('wiza')
23-
it('reveals phone (5 credits) and maps mobile_phone/phones', () => {
29+
it('reveals phone, using name+domain or LinkedIn profile_url', () => {
2430
expect(p.toolId).toBe('wiza_individual_reveal')
25-
expect(p.buildParams(inputs)).toEqual({
31+
expect(p.buildParams(nameDomain)).toEqual({
2632
full_name: 'John Doe',
2733
domain: 'acme.com',
2834
enrichment_level: 'phone',
2935
})
36+
expect(p.buildParams(linkedinOnly)).toEqual({
37+
full_name: 'John Doe',
38+
profile_url: 'https://linkedin.com/in/johndoe',
39+
enrichment_level: 'phone',
40+
})
41+
expect(p.buildParams({ fullName: 'John Doe' })).toBeNull()
3042
expect(p.mapOutput({ mobile_phone: '+1555', phones: [] })).toEqual({ phone: '+1555' })
3143
expect(p.mapOutput({ phones: [{ number: '+1777' }] })).toEqual({ phone: '+1777' })
32-
expect(p.mapOutput({ mobile_phone: null, phone_number: null, phones: [] })).toBeNull()
3344
})
34-
it('skips without a company domain', () => {
35-
expect(p.buildParams({ fullName: 'John Doe', companyDomain: '' })).toBeNull()
45+
})
46+
47+
describe('findymail', () => {
48+
const p = provider('findymail')
49+
it('keys off the LinkedIn URL and skips without one', () => {
50+
expect(p.toolId).toBe('findymail_find_phone')
51+
expect(p.buildParams(linkedinOnly)).toEqual({
52+
linkedin_url: 'https://linkedin.com/in/johndoe',
53+
})
54+
expect(p.buildParams(nameDomain)).toBeNull()
55+
expect(p.mapOutput({ phone: '+1555' })).toEqual({ phone: '+1555' })
56+
expect(p.mapOutput({ phone: null })).toBeNull()
3657
})
3758
})
3859

39-
describe('prospeo', () => {
60+
describe('prospeo (opportunistic)', () => {
4061
const p = provider('prospeo')
41-
it('requests mobile enrichment and maps person.mobile.mobile', () => {
62+
it('requests mobile enrichment via name+domain or LinkedIn', () => {
4263
expect(p.toolId).toBe('prospeo_enrich_person')
43-
expect(p.buildParams(inputs)).toEqual({
64+
expect(p.buildParams(nameDomain)).toEqual({
4465
full_name: 'John Doe',
4566
company_website: 'acme.com',
4667
enrich_mobile: true,
4768
})
48-
expect(p.mapOutput({ person: { mobile: { mobile: '+1555', status: 'VERIFIED' } } })).toEqual({
49-
phone: '+1555',
69+
expect(p.buildParams(linkedinOnly)).toEqual({
70+
full_name: 'John Doe',
71+
linkedin_url: 'https://linkedin.com/in/johndoe',
72+
enrich_mobile: true,
5073
})
51-
expect(p.mapOutput({ person: { mobile: { mobile: '' } } })).toBeNull()
52-
})
53-
it('skips without a company domain', () => {
54-
expect(p.buildParams({ fullName: 'John Doe', companyDomain: '' })).toBeNull()
74+
expect(p.buildParams({ fullName: 'John Doe' })).toBeNull()
75+
expect(p.mapOutput({ person: { mobile: { mobile: '+1555' } } })).toEqual({ phone: '+1555' })
5576
})
5677
})
5778
})

apps/sim/enrichments/phone-number/phone-number.ts

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,22 @@ import { firstNonEmpty, normalizeDomain, str, toolProvider } from '@/enrichments
44
import type { EnrichmentConfig } from '@/enrichments/types'
55

66
/**
7-
* Phone Number enrichment. Finds a contact's phone number from their full name
8-
* and (optionally) company domain via a waterfall: People Data Labs first
9-
* (cheapest, name-only capable), then Wiza and Prospeo mobile reveals as
10-
* fallbacks. Wiza/Prospeo need a company domain, so they self-skip without one.
11-
* The first provider to return a phone wins; all support hosted keys.
7+
* Phone Number enrichment. Finds a contact's phone number from a full name plus
8+
* any available identifiers (company domain, LinkedIn URL) via a waterfall:
9+
* People Data Labs (name match) → Wiza reveal → Findymail (LinkedIn) → Prospeo
10+
* mobile. Each provider opportunistically uses whatever identifiers the row
11+
* provides and self-skips when it has none usable, so adding more inputs widens
12+
* coverage without reordering. First phone wins; all providers support hosted keys.
1213
*/
1314
export const phoneNumberEnrichment: EnrichmentConfig = {
1415
id: 'phone-number',
1516
name: 'Phone Number',
16-
description: "Find a contact's phone number from their name and company domain.",
17+
description: "Find a contact's phone number from their name, company, or LinkedIn URL.",
1718
icon: Phone,
1819
inputs: [
1920
{ id: 'fullName', name: 'Full name', type: 'string', required: true },
2021
{ id: 'companyDomain', name: 'Company domain', type: 'string' },
22+
{ id: 'linkedinUrl', name: 'LinkedIn URL', type: 'string' },
2123
],
2224
outputs: [{ id: 'phone', name: 'phone', type: 'string' }],
2325
providers: [
@@ -45,11 +47,18 @@ export const phoneNumberEnrichment: EnrichmentConfig = {
4547
label: 'Wiza',
4648
toolId: 'wiza_individual_reveal',
4749
buildParams: (inputs) => {
50+
const linkedin = str(inputs.linkedinUrl)
4851
const fullName = str(inputs.fullName)
4952
const domain = normalizeDomain(inputs.companyDomain)
50-
if (!fullName || !domain) return null
51-
// 'phone' reveals the mobile number (5 credits).
52-
return { full_name: fullName, domain, enrichment_level: 'phone' }
53+
// Needs a LinkedIn URL or a name+domain pair; skip otherwise.
54+
if (!linkedin && !(fullName && domain)) return null
55+
// 'phone' reveals the mobile number (5 credits). Prefer LinkedIn when present.
56+
return filterUndefined({
57+
profile_url: linkedin || undefined,
58+
full_name: fullName || undefined,
59+
domain: domain || undefined,
60+
enrichment_level: 'phone',
61+
})
5362
},
5463
mapOutput: (output) => {
5564
const phones = Array.isArray(output.phones)
@@ -59,15 +68,36 @@ export const phoneNumberEnrichment: EnrichmentConfig = {
5968
return phone ? { phone } : null
6069
},
6170
}),
71+
toolProvider({
72+
id: 'findymail',
73+
label: 'Findymail',
74+
toolId: 'findymail_find_phone',
75+
buildParams: (inputs) => {
76+
// Findymail's phone finder keys off a LinkedIn URL only.
77+
const linkedin = str(inputs.linkedinUrl)
78+
if (!linkedin) return null
79+
return { linkedin_url: linkedin }
80+
},
81+
mapOutput: (output) => {
82+
const phone = str(output.phone)
83+
return phone ? { phone } : null
84+
},
85+
}),
6286
toolProvider({
6387
id: 'prospeo',
6488
label: 'Prospeo',
6589
toolId: 'prospeo_enrich_person',
6690
buildParams: (inputs) => {
91+
const linkedin = str(inputs.linkedinUrl)
6792
const fullName = str(inputs.fullName)
6893
const companyWebsite = normalizeDomain(inputs.companyDomain)
69-
if (!fullName || !companyWebsite) return null
70-
return { full_name: fullName, company_website: companyWebsite, enrich_mobile: true }
94+
if (!linkedin && !(fullName && companyWebsite)) return null
95+
return filterUndefined({
96+
linkedin_url: linkedin || undefined,
97+
full_name: fullName || undefined,
98+
company_website: companyWebsite || undefined,
99+
enrich_mobile: true,
100+
})
71101
},
72102
mapOutput: (output) => {
73103
const person = output.person as Record<string, unknown> | undefined

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

Lines changed: 40 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -11,60 +11,76 @@ function provider(id: string): EnrichmentProvider {
1111
return p
1212
}
1313

14-
const inputs = { fullName: 'John Doe', companyDomain: 'https://www.acme.com/careers' }
14+
const nameDomain = { fullName: 'John Doe', companyDomain: 'https://www.acme.com/careers' }
15+
const linkedinOnly = { fullName: 'John Doe', linkedinUrl: 'https://linkedin.com/in/johndoe' }
1516

1617
describe('work-email enrichment cascade', () => {
17-
it('chains the five hosted providers in waterfall order', () => {
18+
it('chains the hosted providers in waterfall order', () => {
1819
expect(workEmailEnrichment.providers.map((p) => p.id)).toEqual([
1920
'hunter',
2021
'findymail',
22+
'findymail-linkedin',
2123
'prospeo',
2224
'wiza',
2325
'pdl',
2426
])
2527
})
2628

27-
describe('findymail', () => {
29+
describe('findymail (name)', () => {
2830
const p = provider('findymail')
29-
it('maps name + normalized domain and extracts contact.email', () => {
31+
it('maps name + domain and extracts contact.email', () => {
3032
expect(p.toolId).toBe('findymail_find_email_from_name')
31-
expect(p.buildParams(inputs)).toEqual({ name: 'John Doe', domain: 'acme.com' })
33+
expect(p.buildParams(nameDomain)).toEqual({ name: 'John Doe', domain: 'acme.com' })
3234
expect(p.mapOutput({ contact: { email: 'j@acme.com' } })).toEqual({ email: 'j@acme.com' })
33-
expect(p.mapOutput({ contact: null })).toBeNull()
35+
expect(p.buildParams(linkedinOnly)).toBeNull()
3436
})
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()
37+
})
38+
39+
describe('findymail-linkedin', () => {
40+
const p = provider('findymail-linkedin')
41+
it('keys off the LinkedIn URL and skips without one', () => {
42+
expect(p.toolId).toBe('findymail_find_email_from_linkedin')
43+
expect(p.buildParams(linkedinOnly)).toEqual({
44+
linkedin_url: 'https://linkedin.com/in/johndoe',
45+
})
46+
expect(p.buildParams(nameDomain)).toBeNull()
47+
expect(p.mapOutput({ contact: { email: 'j@acme.com' } })).toEqual({ email: 'j@acme.com' })
3848
})
3949
})
4050

41-
describe('prospeo', () => {
51+
describe('prospeo (opportunistic)', () => {
4252
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({
53+
it('uses name+domain, or LinkedIn when present', () => {
54+
expect(p.buildParams(nameDomain)).toEqual({
55+
full_name: 'John Doe',
56+
company_website: 'acme.com',
57+
})
58+
expect(p.buildParams(linkedinOnly)).toEqual({
59+
full_name: 'John Doe',
60+
linkedin_url: 'https://linkedin.com/in/johndoe',
61+
})
62+
expect(p.buildParams({ fullName: 'John Doe' })).toBeNull()
63+
expect(p.mapOutput({ person: { email: { email: 'j@acme.com' } } })).toEqual({
4964
email: 'j@acme.com',
5065
})
51-
expect(p.mapOutput({ free_enrichment: true, person: null })).toBeNull()
5266
})
5367
})
5468

55-
describe('wiza', () => {
69+
describe('wiza (opportunistic)', () => {
5670
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({
71+
it('reveals email-only (partial), preferring LinkedIn profile_url', () => {
72+
expect(p.buildParams(nameDomain)).toEqual({
6073
full_name: 'John Doe',
6174
domain: 'acme.com',
6275
enrichment_level: 'partial',
6376
})
64-
expect(p.mapOutput({ email: 'j@acme.com', email_status: 'valid' })).toEqual({
65-
email: 'j@acme.com',
77+
expect(p.buildParams(linkedinOnly)).toEqual({
78+
full_name: 'John Doe',
79+
profile_url: 'https://linkedin.com/in/johndoe',
80+
enrichment_level: 'partial',
6681
})
67-
expect(p.mapOutput({ email: null })).toBeNull()
82+
expect(p.buildParams({ fullName: 'John Doe' })).toBeNull()
83+
expect(p.mapOutput({ email: 'j@acme.com' })).toEqual({ email: 'j@acme.com' })
6884
})
6985
})
7086
})

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

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,23 @@ import { normalizeDomain, splitName, str, toolProvider } from '@/enrichments/pro
44
import type { EnrichmentConfig } from '@/enrichments/types'
55

66
/**
7-
* Work Email enrichment. Finds a person's work email from their full name and
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.
7+
* Work Email enrichment. Finds a person's work email from a full name plus any
8+
* available identifiers (company domain, LinkedIn URL) via a provider waterfall:
9+
* deterministic finders first (Hunter, Findymail by name then by LinkedIn), then
10+
* enrichment/reveal providers (Prospeo, Wiza), then People Data Labs as a broad
11+
* record-match fallback. Each provider opportunistically uses whatever
12+
* identifiers the row provides and self-skips when it has none usable, so adding
13+
* more inputs widens coverage. First email wins; all providers support hosted keys.
1214
*/
1315
export const workEmailEnrichment: EnrichmentConfig = {
1416
id: 'work-email',
1517
name: 'Work Email',
16-
description: "Find a person's work email from their name and company domain.",
18+
description: "Find a person's work email from their name, company, or LinkedIn URL.",
1719
icon: Mail,
1820
inputs: [
1921
{ id: 'fullName', name: 'Full name', type: 'string', required: true },
20-
{ id: 'companyDomain', name: 'Company domain', type: 'string', required: true },
22+
{ id: 'companyDomain', name: 'Company domain', type: 'string' },
23+
{ id: 'linkedinUrl', name: 'LinkedIn URL', type: 'string' },
2124
],
2225
outputs: [{ id: 'email', name: 'email', type: 'string' }],
2326
providers: [
@@ -52,15 +55,35 @@ export const workEmailEnrichment: EnrichmentConfig = {
5255
return email ? { email } : null
5356
},
5457
}),
58+
toolProvider({
59+
id: 'findymail-linkedin',
60+
label: 'Findymail (LinkedIn)',
61+
toolId: 'findymail_find_email_from_linkedin',
62+
buildParams: (inputs) => {
63+
const linkedin = str(inputs.linkedinUrl)
64+
if (!linkedin) return null
65+
return { linkedin_url: linkedin }
66+
},
67+
mapOutput: (output) => {
68+
const contact = output.contact as Record<string, unknown> | null
69+
const email = str(contact?.email)
70+
return email ? { email } : null
71+
},
72+
}),
5573
toolProvider({
5674
id: 'prospeo',
5775
label: 'Prospeo',
5876
toolId: 'prospeo_enrich_person',
5977
buildParams: (inputs) => {
78+
const linkedin = str(inputs.linkedinUrl)
6079
const fullName = str(inputs.fullName)
6180
const companyWebsite = normalizeDomain(inputs.companyDomain)
62-
if (!fullName || !companyWebsite) return null
63-
return { full_name: fullName, company_website: companyWebsite }
81+
if (!linkedin && !(fullName && companyWebsite)) return null
82+
return filterUndefined({
83+
linkedin_url: linkedin || undefined,
84+
full_name: fullName || undefined,
85+
company_website: companyWebsite || undefined,
86+
})
6487
},
6588
mapOutput: (output) => {
6689
const person = output.person as Record<string, unknown> | undefined
@@ -74,11 +97,17 @@ export const workEmailEnrichment: EnrichmentConfig = {
7497
label: 'Wiza',
7598
toolId: 'wiza_individual_reveal',
7699
buildParams: (inputs) => {
100+
const linkedin = str(inputs.linkedinUrl)
77101
const fullName = str(inputs.fullName)
78102
const domain = normalizeDomain(inputs.companyDomain)
79-
if (!fullName || !domain) return null
103+
if (!linkedin && !(fullName && domain)) return null
80104
// 'partial' reveals the email only (2 credits); avoids phone charges.
81-
return { full_name: fullName, domain, enrichment_level: 'partial' }
105+
return filterUndefined({
106+
profile_url: linkedin || undefined,
107+
full_name: fullName || undefined,
108+
domain: domain || undefined,
109+
enrichment_level: 'partial',
110+
})
82111
},
83112
mapOutput: (output) => {
84113
const email = str(output.email)

0 commit comments

Comments
 (0)