linkedin-secret-sauce 0.12.0 → 0.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +50 -21
  2. package/dist/cosiall-client.d.ts +1 -1
  3. package/dist/cosiall-client.js +1 -1
  4. package/dist/enrichment/index.d.ts +3 -3
  5. package/dist/enrichment/index.js +19 -2
  6. package/dist/enrichment/matching.d.ts +29 -9
  7. package/dist/enrichment/matching.js +545 -142
  8. package/dist/enrichment/providers/bounceban.d.ts +82 -0
  9. package/dist/enrichment/providers/bounceban.js +447 -0
  10. package/dist/enrichment/providers/bouncer.d.ts +1 -1
  11. package/dist/enrichment/providers/bouncer.js +19 -21
  12. package/dist/enrichment/providers/construct.d.ts +1 -1
  13. package/dist/enrichment/providers/construct.js +22 -38
  14. package/dist/enrichment/providers/cosiall.d.ts +27 -0
  15. package/dist/enrichment/providers/cosiall.js +109 -0
  16. package/dist/enrichment/providers/dropcontact.d.ts +15 -9
  17. package/dist/enrichment/providers/dropcontact.js +188 -19
  18. package/dist/enrichment/providers/hunter.d.ts +8 -1
  19. package/dist/enrichment/providers/hunter.js +52 -28
  20. package/dist/enrichment/providers/index.d.ts +10 -7
  21. package/dist/enrichment/providers/index.js +12 -1
  22. package/dist/enrichment/providers/ldd.d.ts +1 -10
  23. package/dist/enrichment/providers/ldd.js +20 -97
  24. package/dist/enrichment/providers/smartprospect.js +28 -48
  25. package/dist/enrichment/providers/snovio.d.ts +1 -1
  26. package/dist/enrichment/providers/snovio.js +29 -31
  27. package/dist/enrichment/providers/trykitt.d.ts +63 -0
  28. package/dist/enrichment/providers/trykitt.js +210 -0
  29. package/dist/enrichment/types.d.ts +234 -17
  30. package/dist/enrichment/types.js +60 -48
  31. package/dist/enrichment/utils/candidate-parser.d.ts +107 -0
  32. package/dist/enrichment/utils/candidate-parser.js +173 -0
  33. package/dist/enrichment/utils/noop-provider.d.ts +39 -0
  34. package/dist/enrichment/utils/noop-provider.js +37 -0
  35. package/dist/enrichment/utils/rate-limiter.d.ts +103 -0
  36. package/dist/enrichment/utils/rate-limiter.js +204 -0
  37. package/dist/enrichment/utils/validation.d.ts +75 -3
  38. package/dist/enrichment/utils/validation.js +164 -11
  39. package/dist/linkedin-api.d.ts +40 -1
  40. package/dist/linkedin-api.js +160 -27
  41. package/dist/types.d.ts +50 -1
  42. package/dist/utils/lru-cache.d.ts +105 -0
  43. package/dist/utils/lru-cache.js +175 -0
  44. package/package.json +25 -26
@@ -70,8 +70,8 @@ const ldd_1 = require("./providers/ldd");
70
70
  function salesLeadToContact(lead) {
71
71
  // Use firstName/lastName directly from Sales Nav - they're already separate fields
72
72
  // This preserves exact names as shown on LinkedIn (important for SmartProspect matching)
73
- const firstName = lead.firstName || '';
74
- const lastName = lead.lastName || '';
73
+ const firstName = lead.firstName || "";
74
+ const lastName = lead.lastName || "";
75
75
  return {
76
76
  objectUrn: lead.objectUrn,
77
77
  entityUrn: lead.salesProfileUrn,
@@ -100,7 +100,7 @@ function salesLeadToContact(lead) {
100
100
  */
101
101
  function isSalesLeadSearchResult(input) {
102
102
  // SalesLeadSearchResult has 'name' but not 'firstName'
103
- return 'name' in input && !('firstName' in input);
103
+ return "name" in input && !("firstName" in input);
104
104
  }
105
105
  /**
106
106
  * Normalize input to LinkedInContact - accepts either format
@@ -119,8 +119,8 @@ function normalizeToContact(input) {
119
119
  */
120
120
  function normalize(str) {
121
121
  if (!str)
122
- return '';
123
- return str.toLowerCase().trim().replace(/\s+/g, ' ');
122
+ return "";
123
+ return str.toLowerCase().trim().replace(/\s+/g, " ");
124
124
  }
125
125
  /**
126
126
  * Extract company name from LinkedIn position
@@ -128,15 +128,15 @@ function normalize(str) {
128
128
  function getLinkedInCompany(contact) {
129
129
  const pos = contact.currentPositions?.[0];
130
130
  if (!pos)
131
- return '';
132
- return pos.companyUrnResolutionResult?.name || pos.companyName || '';
131
+ return "";
132
+ return pos.companyUrnResolutionResult?.name || pos.companyName || "";
133
133
  }
134
134
  /**
135
135
  * Extract job title from LinkedIn position
136
136
  */
137
137
  function getLinkedInTitle(contact) {
138
138
  const pos = contact.currentPositions?.[0];
139
- return pos?.title || '';
139
+ return pos?.title || "";
140
140
  }
141
141
  /**
142
142
  * Extract location parts from LinkedIn geoRegion
@@ -144,8 +144,8 @@ function getLinkedInTitle(contact) {
144
144
  */
145
145
  function parseLinkedInLocation(geoRegion) {
146
146
  if (!geoRegion)
147
- return { city: '', state: '', country: '' };
148
- const parts = geoRegion.split(',').map((p) => p.trim());
147
+ return { city: "", state: "", country: "" };
148
+ const parts = geoRegion.split(",").map((p) => p.trim());
149
149
  if (parts.length >= 3) {
150
150
  return {
151
151
  city: parts[0],
@@ -156,15 +156,15 @@ function parseLinkedInLocation(geoRegion) {
156
156
  else if (parts.length === 2) {
157
157
  return {
158
158
  city: parts[0],
159
- state: '',
159
+ state: "",
160
160
  country: parts[1],
161
161
  };
162
162
  }
163
163
  else {
164
164
  return {
165
- city: '',
166
- state: '',
167
- country: parts[0] || '',
165
+ city: "",
166
+ state: "",
167
+ country: parts[0] || "",
168
168
  };
169
169
  }
170
170
  }
@@ -209,7 +209,7 @@ function stringSimilarity(a, b) {
209
209
  * Check if strings match exactly (normalized)
210
210
  */
211
211
  function exactMatch(a, b) {
212
- return normalize(a) === normalize(b) && normalize(a) !== '';
212
+ return normalize(a) === normalize(b) && normalize(a) !== "";
213
213
  }
214
214
  /**
215
215
  * Check if strings match with fuzzy tolerance
@@ -245,50 +245,52 @@ function calculateMatchConfidence(linkedin, smartprospect, options = {}) {
245
245
  // First name match
246
246
  if (exactMatch(linkedin.firstName, smartprospect.firstName)) {
247
247
  score += 20;
248
- matchedFields.push('firstName:exact');
248
+ matchedFields.push("firstName:exact");
249
249
  }
250
- else if (fuzzyNames && fuzzyMatch(linkedin.firstName, smartprospect.firstName)) {
250
+ else if (fuzzyNames &&
251
+ fuzzyMatch(linkedin.firstName, smartprospect.firstName)) {
251
252
  score += 15;
252
- matchedFields.push('firstName:fuzzy');
253
+ matchedFields.push("firstName:fuzzy");
253
254
  }
254
255
  // Last name match
255
256
  if (exactMatch(linkedin.lastName, smartprospect.lastName)) {
256
257
  score += 20;
257
- matchedFields.push('lastName:exact');
258
+ matchedFields.push("lastName:exact");
258
259
  }
259
- else if (fuzzyNames && fuzzyMatch(linkedin.lastName, smartprospect.lastName)) {
260
+ else if (fuzzyNames &&
261
+ fuzzyMatch(linkedin.lastName, smartprospect.lastName)) {
260
262
  score += 15;
261
- matchedFields.push('lastName:fuzzy');
263
+ matchedFields.push("lastName:fuzzy");
262
264
  }
263
265
  // === Company matching (up to 30 points) ===
264
266
  const liCompany = getLinkedInCompany(linkedin);
265
- const spCompany = smartprospect.company?.name || '';
267
+ const spCompany = smartprospect.company?.name || "";
266
268
  if (exactMatch(liCompany, spCompany)) {
267
269
  score += 30;
268
- matchedFields.push('company:exact');
270
+ matchedFields.push("company:exact");
269
271
  }
270
272
  else if (fuzzyCompany && fuzzyMatch(liCompany, spCompany, 0.8)) {
271
273
  score += 25;
272
- matchedFields.push('company:fuzzy');
274
+ matchedFields.push("company:fuzzy");
273
275
  }
274
276
  else if (containsMatch(liCompany, spCompany)) {
275
277
  score += 15;
276
- matchedFields.push('company:contains');
278
+ matchedFields.push("company:contains");
277
279
  }
278
280
  // === Title matching (up to 15 points) ===
279
281
  const liTitle = getLinkedInTitle(linkedin);
280
- const spTitle = smartprospect.title || '';
282
+ const spTitle = smartprospect.title || "";
281
283
  if (exactMatch(liTitle, spTitle)) {
282
284
  score += 15;
283
- matchedFields.push('title:exact');
285
+ matchedFields.push("title:exact");
284
286
  }
285
287
  else if (fuzzyMatch(liTitle, spTitle, 0.75)) {
286
288
  score += 12;
287
- matchedFields.push('title:fuzzy');
289
+ matchedFields.push("title:fuzzy");
288
290
  }
289
291
  else if (containsMatch(liTitle, spTitle)) {
290
292
  score += 8;
291
- matchedFields.push('title:contains');
293
+ matchedFields.push("title:contains");
292
294
  }
293
295
  // === Location matching (up to 15 points) ===
294
296
  const liLocation = parseLinkedInLocation(linkedin.geoRegion);
@@ -298,17 +300,17 @@ function calculateMatchConfidence(linkedin, smartprospect, options = {}) {
298
300
  // Country match
299
301
  if (spCountry && exactMatch(liLocation.country, smartprospect.country)) {
300
302
  score += 5;
301
- matchedFields.push('country:exact');
303
+ matchedFields.push("country:exact");
302
304
  }
303
305
  // State match
304
306
  if (spState && exactMatch(liLocation.state, smartprospect.state)) {
305
307
  score += 5;
306
- matchedFields.push('state:exact');
308
+ matchedFields.push("state:exact");
307
309
  }
308
310
  // City match
309
311
  if (spCity && exactMatch(liLocation.city, smartprospect.city)) {
310
312
  score += 5;
311
- matchedFields.push('city:exact');
313
+ matchedFields.push("city:exact");
312
314
  }
313
315
  return { confidence: Math.min(100, score), matchedFields };
314
316
  }
@@ -316,22 +318,22 @@ function calculateMatchConfidence(linkedin, smartprospect, options = {}) {
316
318
  * Classify match quality based on confidence and matched fields
317
319
  */
318
320
  function classifyMatchQuality(confidence, matchedFields) {
319
- const hasNameMatch = matchedFields.some((f) => f.startsWith('firstName:exact')) &&
320
- matchedFields.some((f) => f.startsWith('lastName:exact'));
321
- const hasCompanyMatch = matchedFields.some((f) => f.startsWith('company:'));
321
+ const hasNameMatch = matchedFields.some((f) => f.startsWith("firstName:exact")) &&
322
+ matchedFields.some((f) => f.startsWith("lastName:exact"));
323
+ const hasCompanyMatch = matchedFields.some((f) => f.startsWith("company:"));
322
324
  if (confidence >= 85 && hasNameMatch && hasCompanyMatch) {
323
- return 'exact';
325
+ return "exact";
324
326
  }
325
327
  else if (confidence >= 70 && hasNameMatch) {
326
- return 'high';
328
+ return "high";
327
329
  }
328
330
  else if (confidence >= 50) {
329
- return 'medium';
331
+ return "medium";
330
332
  }
331
333
  else if (confidence >= 30) {
332
- return 'low';
334
+ return "low";
333
335
  }
334
- return 'none';
336
+ return "none";
335
337
  }
336
338
  /**
337
339
  * Find the best matching SmartProspect contact for a LinkedIn contact
@@ -399,8 +401,8 @@ function parseLinkedInSearchResponse(elements) {
399
401
  return elements.map((el) => ({
400
402
  objectUrn: el.objectUrn,
401
403
  entityUrn: el.entityUrn,
402
- firstName: el.firstName || '',
403
- lastName: el.lastName || '',
404
+ firstName: el.firstName || "",
405
+ lastName: el.lastName || "",
404
406
  fullName: el.fullName || undefined,
405
407
  geoRegion: el.geoRegion || undefined,
406
408
  currentPositions: el.currentPositions,
@@ -457,7 +459,7 @@ async function enrichLinkedInContact(linkedInContact, smartProspectConfig, optio
457
459
  numericLinkedInId,
458
460
  matchedContact: null,
459
461
  matchConfidence: 0,
460
- matchQuality: 'none',
462
+ matchQuality: "none",
461
463
  matchedFields: [],
462
464
  email: null,
463
465
  emailDeliverability: 0,
@@ -468,7 +470,7 @@ async function enrichLinkedInContact(linkedInContact, smartProspectConfig, optio
468
470
  // Create SmartProspect client
469
471
  const client = (0, smartprospect_1.createSmartProspectClient)(smartProspectConfig);
470
472
  if (!client) {
471
- result.error = 'Failed to create SmartProspect client - check credentials';
473
+ result.error = "Failed to create SmartProspect client - check credentials";
472
474
  return result;
473
475
  }
474
476
  try {
@@ -509,12 +511,16 @@ async function enrichLinkedInContact(linkedInContact, smartProspectConfig, optio
509
511
  }
510
512
  }
511
513
  if (result.allCandidates.length === 0) {
512
- result.error = 'No candidates found in SmartProspect';
514
+ result.error = "No candidates found in SmartProspect";
513
515
  return result;
514
516
  }
515
517
  }
516
518
  // Step 2: Find best match using intelligent matching
517
- const matchResult = findBestMatch(linkedInContact, result.allCandidates, { minConfidence, fuzzyNames, fuzzyCompany });
519
+ const matchResult = findBestMatch(linkedInContact, result.allCandidates, {
520
+ minConfidence,
521
+ fuzzyNames,
522
+ fuzzyCompany,
523
+ });
518
524
  if (!matchResult) {
519
525
  result.error = `No match above ${minConfidence}% confidence threshold`;
520
526
  return result;
@@ -525,7 +531,9 @@ async function enrichLinkedInContact(linkedInContact, smartProspectConfig, optio
525
531
  result.matchedFields = matchResult.matchedFields;
526
532
  // Step 3: Fetch email if auto-fetch enabled and good match (COSTS CREDITS)
527
533
  if (autoFetch && matchResult.confidence >= minConfidence) {
528
- const fetchResponse = await client.fetch([matchResult.smartProspectContact.id]);
534
+ const fetchResponse = await client.fetch([
535
+ matchResult.smartProspectContact.id,
536
+ ]);
529
537
  if (fetchResponse.success && fetchResponse.data.list.length > 0) {
530
538
  const enrichedContact = fetchResponse.data.list[0];
531
539
  result.email = enrichedContact.email || null;
@@ -539,7 +547,7 @@ async function enrichLinkedInContact(linkedInContact, smartProspectConfig, optio
539
547
  return result;
540
548
  }
541
549
  catch (err) {
542
- result.error = err instanceof Error ? err.message : 'Unknown error';
550
+ result.error = err instanceof Error ? err.message : "Unknown error";
543
551
  return result;
544
552
  }
545
553
  }
@@ -604,7 +612,7 @@ function createLinkedInEnricher(smartProspectConfig, defaultOptions = {}) {
604
612
  numericLinkedInId,
605
613
  matchedContact: null,
606
614
  matchConfidence: 0,
607
- matchQuality: 'none',
615
+ matchQuality: "none",
608
616
  matchedFields: [],
609
617
  email: null,
610
618
  emailDeliverability: 0,
@@ -634,7 +642,9 @@ function createLinkedInEnricher(smartProspectConfig, defaultOptions = {}) {
634
642
  result.allCandidates = searchResponse.data.list;
635
643
  result.totalCandidatesFound = searchResponse.data.total_count;
636
644
  // Broader search fallback
637
- if (searchResponse.data.list.length === 0 && includeCompany && filters.companyName) {
645
+ if (searchResponse.data.list.length === 0 &&
646
+ includeCompany &&
647
+ filters.companyName) {
638
648
  const broaderResponse = await client.search({
639
649
  firstName: filters.firstName,
640
650
  lastName: filters.lastName,
@@ -646,7 +656,7 @@ function createLinkedInEnricher(smartProspectConfig, defaultOptions = {}) {
646
656
  }
647
657
  }
648
658
  if (result.allCandidates.length === 0) {
649
- result.error = 'No candidates found in SmartProspect';
659
+ result.error = "No candidates found in SmartProspect";
650
660
  return result;
651
661
  }
652
662
  const matchResult = findBestMatch(contact, result.allCandidates, {
@@ -663,12 +673,16 @@ function createLinkedInEnricher(smartProspectConfig, defaultOptions = {}) {
663
673
  result.matchQuality = matchResult.quality;
664
674
  result.matchedFields = matchResult.matchedFields;
665
675
  if (autoFetch && matchResult.confidence >= minConfidence) {
666
- const fetchResponse = await client.fetch([matchResult.smartProspectContact.id]);
676
+ const fetchResponse = await client.fetch([
677
+ matchResult.smartProspectContact.id,
678
+ ]);
667
679
  if (fetchResponse.success && fetchResponse.data.list.length > 0) {
668
680
  const enrichedContact = fetchResponse.data.list[0];
669
681
  result.email = enrichedContact.email || null;
670
- result.emailDeliverability = enrichedContact.emailDeliverability || 0;
671
- result.verificationStatus = enrichedContact.verificationStatus || null;
682
+ result.emailDeliverability =
683
+ enrichedContact.emailDeliverability || 0;
684
+ result.verificationStatus =
685
+ enrichedContact.verificationStatus || null;
672
686
  result.matchedContact = enrichedContact;
673
687
  }
674
688
  }
@@ -676,7 +690,7 @@ function createLinkedInEnricher(smartProspectConfig, defaultOptions = {}) {
676
690
  return result;
677
691
  }
678
692
  catch (err) {
679
- result.error = err instanceof Error ? err.message : 'Unknown error';
693
+ result.error = err instanceof Error ? err.message : "Unknown error";
680
694
  return result;
681
695
  }
682
696
  },
@@ -766,7 +780,7 @@ function createLinkedInEnricher(smartProspectConfig, defaultOptions = {}) {
766
780
  async function getEmailsForLinkedInContact(contactOrLead, config, options = {}) {
767
781
  // Normalize input - accept either LinkedInContact or raw SalesLeadSearchResult
768
782
  const contact = normalizeToContact(contactOrLead);
769
- const { skipLdd = false, skipSmartProspect = false, skipPatternGuessing = false, skipPaidProviders = false, minMatchConfidence = 60, paidProviderThreshold = 80, includeCompany = true, } = options;
783
+ const { skipLdd = false, skipSmartProspect = false, skipCosiall = false, skipTryKitt = false, skipPatternGuessing = false, skipBounceBan = false, skipPaidProviders = false, minMatchConfidence = 60, paidProviderThreshold = 80, includeCompany = true, } = options;
770
784
  // Extract numeric ID from objectUrn
771
785
  const numericLinkedInId = (0, ldd_1.extractNumericLinkedInId)(contact.objectUrn);
772
786
  // Extract company domain from contact (LinkedIn data - often missing)
@@ -778,113 +792,220 @@ async function getEmailsForLinkedInContact(contactOrLead, config, options = {})
778
792
  providersQueried: [],
779
793
  errors: [],
780
794
  };
781
- // Track seen emails to deduplicate
782
- const seenEmails = new Set();
795
+ // Track all emails with their sources for confidence boosting
796
+ const emailsByAddress = new Map();
783
797
  const addEmail = (emailResult) => {
784
798
  const lower = emailResult.email.toLowerCase();
785
- if (!seenEmails.has(lower)) {
786
- seenEmails.add(lower);
787
- result.emails.push(emailResult);
799
+ const existing = emailsByAddress.get(lower);
800
+ if (existing) {
801
+ // Email already found from another source - track for confidence boosting
802
+ existing.results.push(emailResult);
803
+ existing.sources.add(emailResult.source);
804
+ }
805
+ else {
806
+ emailsByAddress.set(lower, {
807
+ results: [emailResult],
808
+ sources: new Set([emailResult.source]),
809
+ });
810
+ }
811
+ };
812
+ /**
813
+ * Merge emails and boost confidence when found from multiple sources
814
+ * - 2 sources: boost to at least 95% confidence
815
+ * - 3+ sources: boost to 100% confidence
816
+ */
817
+ const mergeAndBoostEmails = () => {
818
+ const mergedEmails = [];
819
+ for (const [_email, { results, sources }] of emailsByAddress) {
820
+ // Find the best result (highest confidence)
821
+ const bestResult = results.reduce((best, current) => current.confidence > best.confidence ? current : best);
822
+ // Boost confidence based on number of sources
823
+ let boostedConfidence = bestResult.confidence;
824
+ if (sources.size >= 3) {
825
+ boostedConfidence = 100;
826
+ }
827
+ else if (sources.size === 2) {
828
+ boostedConfidence = Math.max(boostedConfidence, 95);
829
+ }
830
+ // Create merged result
831
+ const mergedResult = {
832
+ ...bestResult,
833
+ confidence: boostedConfidence,
834
+ metadata: {
835
+ ...bestResult.metadata,
836
+ foundBySources: Array.from(sources),
837
+ sourceCount: sources.size,
838
+ confidenceBoosted: sources.size > 1,
839
+ originalConfidence: bestResult.confidence,
840
+ },
841
+ };
842
+ // If verified by any source, mark as verified
843
+ if (results.some((r) => r.verified)) {
844
+ mergedResult.verified = true;
845
+ }
846
+ mergedEmails.push(mergedResult);
788
847
  }
848
+ return mergedEmails;
789
849
  };
790
850
  // Track company domain discovered from SmartProspect (since LinkedIn doesn't provide it)
791
851
  let discoveredCompanyDomain = null;
792
852
  // ==========================================================================
793
- // Phase 1: FREE providers in PARALLEL (LDD + SmartProspect)
853
+ // Phase 1: FREE database providers in PARALLEL (no LinkedIn API calls)
854
+ // LDD + SmartProspect + Cosiall - these are your own databases or already paid
794
855
  // ==========================================================================
795
856
  const freeProviderPromises = [];
796
- // LDD lookup (FREE)
857
+ // LDD lookup (FREE - your ~500M database)
797
858
  if (!skipLdd && config.ldd?.apiUrl && config.ldd?.apiToken) {
798
- result.providersQueried.push('ldd');
859
+ result.providersQueried.push("ldd");
799
860
  freeProviderPromises.push(queryLdd(contact, config.ldd, numericLinkedInId, addEmail, result));
800
861
  }
801
- // SmartProspect lookup (FREE for FlexIQ)
802
- // This also extracts company domain for pattern guessing
862
+ // SmartProspect lookup (FREE for FlexIQ - already paying monthly)
863
+ // This also extracts company domain for pattern guessing (no extra API call)
803
864
  if (!skipSmartProspect && config.smartprospect) {
804
- result.providersQueried.push('smartprospect');
805
- freeProviderPromises.push(querySmartProspect(contact, config.smartprospect, minMatchConfidence, includeCompany, addEmail, result)
806
- .then((domain) => {
865
+ result.providersQueried.push("smartprospect");
866
+ freeProviderPromises.push(querySmartProspect(contact, config.smartprospect, minMatchConfidence, includeCompany, addEmail, result).then((domain) => {
807
867
  if (domain) {
808
868
  discoveredCompanyDomain = domain;
809
869
  }
810
870
  }));
811
871
  }
812
- // Wait for both free providers
872
+ // Cosiall Profile Emails lookup (FREE - your database)
873
+ if (!skipCosiall && config.cosiall?.enabled !== false) {
874
+ result.providersQueried.push("cosiall");
875
+ freeProviderPromises.push(queryCosiall(contact, addEmail, result));
876
+ }
877
+ // TryKitt.ai lookup (FREE for individuals - unlimited)
878
+ // AI-powered email finder with enterprise identity server catch-all verification
879
+ if (!skipTryKitt && config.trykitt?.apiKey) {
880
+ result.providersQueried.push("trykitt");
881
+ freeProviderPromises.push(queryTryKitt(contact, config.trykitt, linkedInCompanyDomain || discoveredCompanyDomain, addEmail, result));
882
+ }
883
+ // Wait for all Phase 1 free providers
813
884
  await Promise.all(freeProviderPromises);
814
- // Check if we have good enough results already
815
- const bestConfidenceAfterFreeProviders = result.emails.length > 0
816
- ? Math.max(...result.emails.map(e => e.confidence))
885
+ // Merge emails and calculate best confidence after Phase 1
886
+ result.emails = mergeAndBoostEmails();
887
+ const phase1BestConfidence = result.emails.length > 0
888
+ ? Math.max(...result.emails.map((e) => e.confidence))
817
889
  : 0;
818
890
  // ==========================================================================
819
- // Phase 2: Domain Discovery (if needed for pattern guessing)
891
+ // Phase 2: Domain Discovery + Pattern Guessing
892
+ // ONLY if Phase 1 confidence < threshold (to minimize LinkedIn API calls)
820
893
  // ==========================================================================
821
- // Priority: LinkedIn contact data > SmartProspect > LinkedIn Company API
822
894
  let companyDomain = linkedInCompanyDomain || discoveredCompanyDomain;
823
- // If no domain yet and pattern guessing is enabled, try LinkedIn company lookup
824
- if (!companyDomain &&
825
- !skipPatternGuessing &&
826
- !options.skipLinkedInCompanyLookup &&
827
- config.linkedInCompanyLookup &&
828
- bestConfidenceAfterFreeProviders < paidProviderThreshold) {
829
- // Extract company ID from companyUrn
830
- const companyUrn = contact.currentPositions?.[0]?.companyUrn;
831
- if (companyUrn) {
832
- const companyId = extractCompanyIdFromUrn(companyUrn);
833
- if (companyId) {
834
- result.providersQueried.push('linkedin');
835
- try {
836
- const company = await config.linkedInCompanyLookup(companyId);
837
- if (company?.websiteUrl) {
838
- companyDomain = extractDomainFromUrl(company.websiteUrl);
839
- if (companyDomain) {
840
- discoveredCompanyDomain = companyDomain;
895
+ // Skip Phase 2 entirely if we already have high confidence emails
896
+ if (phase1BestConfidence < paidProviderThreshold) {
897
+ // Try LinkedIn Company Lookup for domain discovery (EXPENSIVE - uses LinkedIn API)
898
+ // Only if we don't have a domain yet
899
+ if (!companyDomain &&
900
+ !skipPatternGuessing &&
901
+ !options.skipLinkedInCompanyLookup &&
902
+ config.linkedInCompanyLookup) {
903
+ const companyUrn = contact.currentPositions?.[0]?.companyUrn;
904
+ if (companyUrn) {
905
+ const companyId = extractCompanyIdFromUrn(companyUrn);
906
+ if (companyId) {
907
+ result.providersQueried.push("linkedin");
908
+ try {
909
+ const company = await config.linkedInCompanyLookup(companyId);
910
+ if (company?.websiteUrl) {
911
+ companyDomain = extractDomainFromUrl(company.websiteUrl);
912
+ if (companyDomain) {
913
+ discoveredCompanyDomain = companyDomain;
914
+ }
841
915
  }
842
916
  }
843
- }
844
- catch (err) {
845
- result.errors?.push(`LinkedIn: ${err instanceof Error ? err.message : 'Company lookup failed'}`);
917
+ catch (err) {
918
+ result.errors?.push(`LinkedIn: ${err instanceof Error ? err.message : "Company lookup failed"}`);
919
+ }
846
920
  }
847
921
  }
848
922
  }
923
+ // Store discovered domain in result for visibility
924
+ if (discoveredCompanyDomain && !linkedInCompanyDomain) {
925
+ result.discoveredCompanyDomain = discoveredCompanyDomain;
926
+ }
927
+ // Run pattern guessing if we have a domain (from LinkedIn contact, SmartProspect, or LinkedIn API)
928
+ if (!skipPatternGuessing && companyDomain) {
929
+ result.providersQueried.push("pattern");
930
+ await queryPatternGuessing(contact, companyDomain, addEmail, result);
931
+ // Re-merge after pattern guessing
932
+ result.emails = mergeAndBoostEmails();
933
+ }
934
+ // BounceBan catch-all verification (FREE single emails)
935
+ // Use BounceBan to verify pattern-guessed emails, especially on catch-all domains
936
+ if (!skipBounceBan &&
937
+ config.bounceban?.apiKey &&
938
+ result.emails.length > 0) {
939
+ // Only verify emails that are from pattern guessing or have low confidence
940
+ const emailsToVerify = result.emails
941
+ .filter((e) => e.source === "pattern" || (e.confidence < 80 && !e.verified))
942
+ .slice(0, 3); // Verify top 3 to save credits
943
+ if (emailsToVerify.length > 0) {
944
+ result.providersQueried.push("bounceban");
945
+ await queryBounceBan(emailsToVerify, config.bounceban, addEmail, result);
946
+ // Re-merge after BounceBan verification
947
+ result.emails = mergeAndBoostEmails();
948
+ }
949
+ }
849
950
  }
850
- // Store discovered domain in result for visibility
851
- if (discoveredCompanyDomain && !linkedInCompanyDomain) {
852
- result.discoveredCompanyDomain = discoveredCompanyDomain;
853
- }
854
- // ==========================================================================
855
- // Phase 3: Email pattern guessing with MX verification (FREE)
856
- // ==========================================================================
857
- if (!skipPatternGuessing && companyDomain && bestConfidenceAfterFreeProviders < paidProviderThreshold) {
858
- result.providersQueried.push('pattern');
859
- await queryPatternGuessing(contact, companyDomain, addEmail, result);
860
- }
861
- // Recalculate best confidence
951
+ // Calculate final best confidence
862
952
  const finalBestConfidence = result.emails.length > 0
863
- ? Math.max(...result.emails.map(e => e.confidence))
953
+ ? Math.max(...result.emails.map((e) => e.confidence))
864
954
  : 0;
865
955
  // ==========================================================================
866
956
  // Phase 3: PAID providers as last resort (Hunter/Bouncer/Snovio)
867
957
  // ==========================================================================
868
958
  if (!skipPaidProviders && finalBestConfidence < paidProviderThreshold) {
869
959
  // Only use paid providers if we have low confidence or no results
870
- // TODO: Implement Hunter, Bouncer, Snovio providers when needed
871
- // For now, just mark that we would have queried them
872
- if (config.hunter?.apiKey || config.bouncer?.apiKey || config.snovio?.userId) {
873
- // result.providersQueried.push('hunter');
874
- // result.providersQueried.push('bouncer');
875
- // result.providersQueried.push('snovio');
876
- // await queryPaidProviders(contact, config, addEmail, result);
960
+ // Hunter.io - Email Finder ($0.005/lookup)
961
+ if (config.hunter?.apiKey) {
962
+ result.providersQueried.push("hunter");
963
+ // Extract linkedin handle from contact if available
964
+ // Note: For best results, caller should fetch profile with getSalesNavigatorProfileFull
965
+ // and extract handle using extractLinkedInHandle(profile.flagshipProfileUrl)
966
+ let linkedinHandle = null;
967
+ // Try to extract from entityUrn or any linkedin URL fields
968
+ // (These won't typically have the vanity, but check anyway)
969
+ const contactAny = contact;
970
+ if (contactAny.linkedinHandle) {
971
+ linkedinHandle = String(contactAny.linkedinHandle);
972
+ }
973
+ else if (contactAny.flagshipProfileUrl) {
974
+ // Extract handle from flagship URL
975
+ const match = String(contactAny.flagshipProfileUrl).match(/linkedin\.com\/in\/([^\/\?]+)/i);
976
+ if (match)
977
+ linkedinHandle = match[1];
978
+ }
979
+ await queryHunter(contact, config.hunter, companyDomain || null, linkedinHandle, addEmail, result);
980
+ // Re-merge after Hunter
981
+ result.emails = mergeAndBoostEmails();
877
982
  }
983
+ // Snovio - Email Finder ($0.02/lookup) - best for catch-all domains
984
+ if (config.snovio?.userId && config.snovio?.apiSecret && companyDomain) {
985
+ result.providersQueried.push("snovio");
986
+ await querySnovio(contact, config.snovio, companyDomain, addEmail, result);
987
+ // Re-merge after Snovio
988
+ result.emails = mergeAndBoostEmails();
989
+ }
990
+ // Note: Bouncer is a VERIFICATION provider (verifies existing emails).
991
+ // It's automatically used by the construct provider for SMTP verification.
992
+ // No need to call it separately here since pattern guessing already uses
993
+ // the built-in mx verification which is similar to Bouncer.
878
994
  }
995
+ // Final merge (in case paid providers added emails) and sort
996
+ result.emails = mergeAndBoostEmails();
879
997
  // Sort emails by confidence (highest first), then by source priority
880
998
  const sourcePriority = {
881
999
  ldd: 0, // Highest priority - your own data
882
1000
  smartprospect: 1,
883
- linkedin: 2, // LinkedIn company lookup (for domain discovery, doesn't provide emails)
884
- pattern: 3,
885
- hunter: 4,
886
- bouncer: 5,
887
- snovio: 6,
1001
+ cosiall: 2, // Cosiall Profile Emails (FREE)
1002
+ trykitt: 3, // TryKitt.ai (FREE for individuals)
1003
+ linkedin: 4, // LinkedIn company lookup (for domain discovery, doesn't provide emails)
1004
+ pattern: 5,
1005
+ bounceban: 6, // BounceBan catch-all verification (FREE single)
1006
+ hunter: 7,
1007
+ bouncer: 8,
1008
+ snovio: 9,
888
1009
  };
889
1010
  result.emails.sort((a, b) => {
890
1011
  if (b.confidence !== a.confidence) {
@@ -920,12 +1041,15 @@ function extractDomainFromUrl(url) {
920
1041
  if (!url)
921
1042
  return null;
922
1043
  try {
923
- const fullUrl = url.startsWith('http') ? url : `https://${url}`;
924
- return new URL(fullUrl).hostname.replace(/^www\./, '').toLowerCase();
1044
+ const fullUrl = url.startsWith("http") ? url : `https://${url}`;
1045
+ return new URL(fullUrl).hostname.replace(/^www\./, "").toLowerCase();
925
1046
  }
926
1047
  catch {
927
1048
  // If URL parsing fails, try simple extraction
928
- return url.replace(/^(https?:\/\/)?(www\.)?/, '').split('/')[0].toLowerCase() || null;
1049
+ return (url
1050
+ .replace(/^(https?:\/\/)?(www\.)?/, "")
1051
+ .split("/")[0]
1052
+ .toLowerCase() || null);
929
1053
  }
930
1054
  }
931
1055
  /**
@@ -950,13 +1074,13 @@ async function queryLdd(contact, lddConfig, numericLinkedInId, addEmail, result)
950
1074
  firstName: contact.firstName,
951
1075
  lastName: contact.lastName,
952
1076
  });
953
- if (lddResult && 'emails' in lddResult && lddResult.emails.length > 0) {
1077
+ if (lddResult && "emails" in lddResult && lddResult.emails.length > 0) {
954
1078
  for (const emailData of lddResult.emails) {
955
1079
  addEmail({
956
1080
  email: emailData.email,
957
- source: 'ldd',
1081
+ source: "ldd",
958
1082
  confidence: emailData.confidence ?? 90,
959
- type: emailData.metadata?.emailTypeClassified || 'unknown',
1083
+ type: emailData.metadata?.emailTypeClassified || "unknown",
960
1084
  verified: emailData.verified ?? true,
961
1085
  metadata: emailData.metadata,
962
1086
  });
@@ -964,7 +1088,166 @@ async function queryLdd(contact, lddConfig, numericLinkedInId, addEmail, result)
964
1088
  }
965
1089
  }
966
1090
  catch (err) {
967
- result.errors?.push(`LDD: ${err instanceof Error ? err.message : 'Unknown error'}`);
1091
+ result.errors?.push(`LDD: ${err instanceof Error ? err.message : "Unknown error"}`);
1092
+ }
1093
+ }
1094
+ /**
1095
+ * Query Cosiall Profile Emails provider
1096
+ */
1097
+ async function queryCosiall(contact, addEmail, result) {
1098
+ try {
1099
+ // Import dynamically to avoid circular deps
1100
+ const { fetchProfileEmailsFromCosiall } = await Promise.resolve().then(() => __importStar(require("../cosiall-client")));
1101
+ // Build lookup parameters from contact
1102
+ const objectUrn = contact.objectUrn;
1103
+ const linkedInUrl = contact.entityUrn
1104
+ ? undefined // entityUrn is not a URL
1105
+ : undefined;
1106
+ // Extract vanity from contact if available
1107
+ // (LinkedInContact doesn't have direct username field, but could be derived)
1108
+ // Must have at least objectUrn to query Cosiall
1109
+ if (!objectUrn) {
1110
+ return; // Silently skip - no identifier available
1111
+ }
1112
+ const cosiallResult = await fetchProfileEmailsFromCosiall({
1113
+ objectUrn,
1114
+ linkedInUrl,
1115
+ });
1116
+ if (cosiallResult.emails && cosiallResult.emails.length > 0) {
1117
+ for (const email of cosiallResult.emails) {
1118
+ addEmail({
1119
+ email,
1120
+ source: "cosiall",
1121
+ confidence: 85, // Good confidence - profile-associated emails
1122
+ type: "unknown", // Cosiall doesn't classify email type
1123
+ verified: true, // These are from LinkedIn profiles
1124
+ metadata: {
1125
+ profileId: cosiallResult.profileId,
1126
+ objectUrn: cosiallResult.objectUrn,
1127
+ linkedInUrl: cosiallResult.linkedInUrl,
1128
+ },
1129
+ });
1130
+ }
1131
+ }
1132
+ }
1133
+ catch (err) {
1134
+ // Silently fail for NOT_FOUND - just means profile not in database
1135
+ const errorMessage = err instanceof Error ? err.message : "Unknown error";
1136
+ if (!errorMessage.includes("not found") &&
1137
+ !errorMessage.includes("NOT_FOUND")) {
1138
+ result.errors?.push(`Cosiall: ${errorMessage}`);
1139
+ }
1140
+ }
1141
+ }
1142
+ /**
1143
+ * Query TryKitt.ai provider (FREE for individuals)
1144
+ * AI-powered email finder with enterprise identity server catch-all verification
1145
+ */
1146
+ async function queryTryKitt(contact, trykittConfig, companyDomain, addEmail, result) {
1147
+ try {
1148
+ // Import TryKitt provider dynamically
1149
+ const { createTryKittProvider } = await Promise.resolve().then(() => __importStar(require("./providers/trykitt")));
1150
+ const trykittProvider = createTryKittProvider(trykittConfig);
1151
+ // Build candidate with available data
1152
+ const fullName = contact.fullName || `${contact.firstName} ${contact.lastName}`.trim();
1153
+ // Need both name and domain to search
1154
+ if (!fullName || !companyDomain) {
1155
+ return; // Silently skip - missing required data
1156
+ }
1157
+ const candidate = {
1158
+ fullName,
1159
+ firstName: contact.firstName,
1160
+ lastName: contact.lastName,
1161
+ domain: companyDomain,
1162
+ };
1163
+ // Add LinkedIn URL if available (improves TryKitt accuracy)
1164
+ if (contact.entityUrn) {
1165
+ // entityUrn is like "urn:li:fs_salesProfile:..." - not useful for TryKitt
1166
+ }
1167
+ const trykittResult = await trykittProvider(candidate);
1168
+ if (trykittResult) {
1169
+ // Single result
1170
+ if ("email" in trykittResult && trykittResult.email) {
1171
+ addEmail({
1172
+ email: trykittResult.email,
1173
+ source: "trykitt",
1174
+ confidence: trykittResult.score ?? 80,
1175
+ type: "business",
1176
+ verified: trykittResult.verified ?? false,
1177
+ metadata: {
1178
+ provider: "trykitt",
1179
+ aiPowered: true,
1180
+ },
1181
+ });
1182
+ }
1183
+ // Multi result
1184
+ else if ("emails" in trykittResult) {
1185
+ const multiResult = trykittResult;
1186
+ for (const emailData of multiResult.emails) {
1187
+ addEmail({
1188
+ email: emailData.email,
1189
+ source: "trykitt",
1190
+ confidence: emailData.confidence ?? 70,
1191
+ type: "business",
1192
+ verified: emailData.verified ?? false,
1193
+ isCatchAll: emailData.isCatchAll,
1194
+ metadata: emailData.metadata,
1195
+ });
1196
+ }
1197
+ }
1198
+ }
1199
+ }
1200
+ catch (err) {
1201
+ result.errors?.push(`TryKitt: ${err instanceof Error ? err.message : "Unknown error"}`);
1202
+ }
1203
+ }
1204
+ /**
1205
+ * Query BounceBan for catch-all verification
1206
+ * Verifies pattern-guessed emails with 85-95% accuracy on catch-all domains
1207
+ */
1208
+ async function queryBounceBan(emailsToVerify, bouncebanConfig, addEmail, result) {
1209
+ try {
1210
+ // Import BounceBan provider dynamically
1211
+ const { verifyEmailWithBounceBan } = await Promise.resolve().then(() => __importStar(require("./providers/bounceban")));
1212
+ for (const emailResult of emailsToVerify) {
1213
+ const verification = await verifyEmailWithBounceBan(emailResult.email, {
1214
+ apiKey: bouncebanConfig.apiKey,
1215
+ useDeepVerify: bouncebanConfig.useDeepVerify ?? false,
1216
+ useWaterfall: bouncebanConfig.useWaterfall ?? true,
1217
+ });
1218
+ if (verification && verification.status === "success") {
1219
+ // Map BounceBan result to confidence
1220
+ let confidence = verification.score;
1221
+ const verified = verification.result === "deliverable";
1222
+ // Reduce confidence for catch-all domains even if deliverable
1223
+ if (verification.is_accept_all && verified) {
1224
+ confidence = Math.min(confidence, 75);
1225
+ }
1226
+ // Only add if it's potentially deliverable
1227
+ if (verification.result === "deliverable" ||
1228
+ verification.result === "risky") {
1229
+ addEmail({
1230
+ email: verification.email,
1231
+ source: "bounceban",
1232
+ confidence,
1233
+ type: "business",
1234
+ verified,
1235
+ isCatchAll: verification.is_accept_all,
1236
+ metadata: {
1237
+ bounceBanResult: verification.result,
1238
+ bounceBanScore: verification.score,
1239
+ smtpProvider: verification.smtp_provider,
1240
+ isDisposable: verification.is_disposable,
1241
+ isRole: verification.is_role,
1242
+ originalSource: emailResult.source,
1243
+ },
1244
+ });
1245
+ }
1246
+ }
1247
+ }
1248
+ }
1249
+ catch (err) {
1250
+ result.errors?.push(`BounceBan: ${err instanceof Error ? err.message : "Unknown error"}`);
968
1251
  }
969
1252
  }
970
1253
  /**
@@ -975,7 +1258,7 @@ async function querySmartProspect(contact, smartProspectConfig, minMatchConfiden
975
1258
  try {
976
1259
  const client = (0, smartprospect_1.createSmartProspectClient)(smartProspectConfig);
977
1260
  if (!client) {
978
- result.errors?.push('SmartProspect: Failed to create client');
1261
+ result.errors?.push("SmartProspect: Failed to create client");
979
1262
  return null;
980
1263
  }
981
1264
  // Build search filters
@@ -1021,21 +1304,23 @@ async function querySmartProspect(contact, smartProspectConfig, minMatchConfiden
1021
1304
  if (companyWebsite) {
1022
1305
  // Clean up the domain (remove protocol, www, trailing slashes)
1023
1306
  discoveredDomain = companyWebsite
1024
- .replace(/^(https?:\/\/)?(www\.)?/, '')
1025
- .split('/')[0]
1307
+ .replace(/^(https?:\/\/)?(www\.)?/, "")
1308
+ .split("/")[0]
1026
1309
  .toLowerCase();
1027
1310
  }
1028
1311
  // Fetch email for matched contact
1029
- const fetchResponse = await client.fetch([matchResult.smartProspectContact.id]);
1312
+ const fetchResponse = await client.fetch([
1313
+ matchResult.smartProspectContact.id,
1314
+ ]);
1030
1315
  if (fetchResponse.success && fetchResponse.data.list.length > 0) {
1031
1316
  const enrichedContact = fetchResponse.data.list[0];
1032
1317
  if (enrichedContact.email) {
1033
1318
  addEmail({
1034
1319
  email: enrichedContact.email,
1035
- source: 'smartprospect',
1320
+ source: "smartprospect",
1036
1321
  confidence: matchResult.confidence,
1037
- type: 'business',
1038
- verified: enrichedContact.verificationStatus === 'verified',
1322
+ type: "business",
1323
+ verified: enrichedContact.verificationStatus === "verified",
1039
1324
  deliverability: enrichedContact.emailDeliverability,
1040
1325
  metadata: {
1041
1326
  matchQuality: matchResult.quality,
@@ -1051,7 +1336,7 @@ async function querySmartProspect(contact, smartProspectConfig, minMatchConfiden
1051
1336
  return discoveredDomain;
1052
1337
  }
1053
1338
  catch (err) {
1054
- result.errors?.push(`SmartProspect: ${err instanceof Error ? err.message : 'Unknown error'}`);
1339
+ result.errors?.push(`SmartProspect: ${err instanceof Error ? err.message : "Unknown error"}`);
1055
1340
  return null;
1056
1341
  }
1057
1342
  }
@@ -1061,20 +1346,25 @@ async function querySmartProspect(contact, smartProspectConfig, minMatchConfiden
1061
1346
  async function queryPatternGuessing(contact, companyDomain, addEmail, result) {
1062
1347
  try {
1063
1348
  // Import construct provider dynamically to avoid circular deps
1064
- const { createConstructProvider } = await Promise.resolve().then(() => __importStar(require('./providers/construct')));
1065
- const constructProvider = createConstructProvider({ maxAttempts: 12, timeoutMs: 3000 });
1349
+ const { createConstructProvider } = await Promise.resolve().then(() => __importStar(require("./providers/construct")));
1350
+ const constructProvider = createConstructProvider({
1351
+ maxAttempts: 12,
1352
+ timeoutMs: 3000,
1353
+ });
1066
1354
  const constructResult = await constructProvider({
1067
1355
  firstName: contact.firstName,
1068
1356
  lastName: contact.lastName,
1069
1357
  domain: companyDomain,
1070
1358
  });
1071
- if (constructResult && 'emails' in constructResult && constructResult.emails.length > 0) {
1359
+ if (constructResult &&
1360
+ "emails" in constructResult &&
1361
+ constructResult.emails.length > 0) {
1072
1362
  for (const emailData of constructResult.emails) {
1073
1363
  addEmail({
1074
1364
  email: emailData.email,
1075
- source: 'pattern',
1365
+ source: "pattern",
1076
1366
  confidence: emailData.confidence ?? 50,
1077
- type: 'business',
1367
+ type: "business",
1078
1368
  verified: emailData.verified ?? false,
1079
1369
  isCatchAll: emailData.isCatchAll,
1080
1370
  metadata: emailData.metadata,
@@ -1083,7 +1373,120 @@ async function queryPatternGuessing(contact, companyDomain, addEmail, result) {
1083
1373
  }
1084
1374
  }
1085
1375
  catch (err) {
1086
- result.errors?.push(`Pattern: ${err instanceof Error ? err.message : 'Unknown error'}`);
1376
+ result.errors?.push(`Pattern: ${err instanceof Error ? err.message : "Unknown error"}`);
1377
+ }
1378
+ }
1379
+ /**
1380
+ * Query Hunter.io Email Finder (PAID provider)
1381
+ *
1382
+ * Supports multiple modes:
1383
+ * 1. linkedin_handle - Most accurate (requires getSalesNavigatorProfileFull to get handle)
1384
+ * 2. domain + first_name + last_name
1385
+ * 3. company + first_name + last_name
1386
+ */
1387
+ async function queryHunter(contact, hunterConfig, companyDomain, linkedinHandle, addEmail, result) {
1388
+ try {
1389
+ // Import Hunter provider dynamically
1390
+ const { createHunterProvider } = await Promise.resolve().then(() => __importStar(require("./providers/hunter")));
1391
+ const hunterProvider = createHunterProvider(hunterConfig);
1392
+ // Build candidate with all available data
1393
+ const candidate = {
1394
+ firstName: contact.firstName,
1395
+ lastName: contact.lastName,
1396
+ };
1397
+ // Add linkedin_handle if available (best for Hunter)
1398
+ if (linkedinHandle) {
1399
+ candidate.linkedinHandle = linkedinHandle;
1400
+ }
1401
+ // Add domain if available
1402
+ if (companyDomain) {
1403
+ candidate.domain = companyDomain;
1404
+ }
1405
+ // Add company name if available
1406
+ const companyName = contact.currentPositions?.[0]?.companyName;
1407
+ if (companyName) {
1408
+ candidate.company = companyName;
1409
+ }
1410
+ const hunterResult = await hunterProvider(candidate);
1411
+ if (hunterResult) {
1412
+ // Single result
1413
+ if ("email" in hunterResult && hunterResult.email) {
1414
+ addEmail({
1415
+ email: hunterResult.email,
1416
+ source: "hunter",
1417
+ confidence: hunterResult.score ?? 70,
1418
+ type: "business",
1419
+ verified: hunterResult.verified ?? false,
1420
+ metadata: {
1421
+ usedLinkedInHandle: !!linkedinHandle,
1422
+ usedDomain: !!companyDomain,
1423
+ usedCompany: !!companyName && !companyDomain && !linkedinHandle,
1424
+ },
1425
+ });
1426
+ }
1427
+ // Multi result (from domain-search)
1428
+ else if ("emails" in hunterResult && hunterResult.emails.length > 0) {
1429
+ for (const emailData of hunterResult.emails) {
1430
+ addEmail({
1431
+ email: emailData.email,
1432
+ source: "hunter",
1433
+ confidence: emailData.confidence ?? 60,
1434
+ type: "business",
1435
+ verified: emailData.verified ?? false,
1436
+ metadata: emailData.metadata,
1437
+ });
1438
+ }
1439
+ }
1440
+ }
1441
+ }
1442
+ catch (err) {
1443
+ result.errors?.push(`Hunter: ${err instanceof Error ? err.message : "Unknown error"}`);
1444
+ }
1445
+ }
1446
+ /**
1447
+ * Query Snovio for email addresses
1448
+ */
1449
+ async function querySnovio(contact, snovioConfig, companyDomain, addEmail, result) {
1450
+ try {
1451
+ // Import Snovio provider dynamically
1452
+ const { createSnovioProvider } = await Promise.resolve().then(() => __importStar(require("./providers/snovio")));
1453
+ const snovioProvider = createSnovioProvider(snovioConfig);
1454
+ // Build candidate with available data
1455
+ const candidate = {
1456
+ firstName: contact.firstName,
1457
+ lastName: contact.lastName,
1458
+ domain: companyDomain,
1459
+ };
1460
+ const snovioResult = await snovioProvider(candidate);
1461
+ if (snovioResult) {
1462
+ // Single result
1463
+ if ("email" in snovioResult && snovioResult.email) {
1464
+ addEmail({
1465
+ email: snovioResult.email,
1466
+ source: "snovio",
1467
+ confidence: snovioResult.score ?? 70,
1468
+ type: "business",
1469
+ verified: snovioResult.verified ?? false,
1470
+ });
1471
+ }
1472
+ // Multi result
1473
+ else if ("emails" in snovioResult && snovioResult.emails.length > 0) {
1474
+ for (const emailData of snovioResult.emails) {
1475
+ addEmail({
1476
+ email: emailData.email,
1477
+ source: "snovio",
1478
+ confidence: emailData.confidence ?? 60,
1479
+ type: "business",
1480
+ verified: emailData.verified ?? false,
1481
+ isCatchAll: emailData.isCatchAll,
1482
+ metadata: emailData.metadata,
1483
+ });
1484
+ }
1485
+ }
1486
+ }
1487
+ }
1488
+ catch (err) {
1489
+ result.errors?.push(`Snovio: ${err instanceof Error ? err.message : "Unknown error"}`);
1087
1490
  }
1088
1491
  }
1089
1492
  /**