Skip to main content
skill CX Codex

email-enrichment — Claude Code skill: what it does, how to install

Turns email lists into person and company profiles through Crustdata Code Mode MCP. It runs a six-phase lookup waterfall to find contact details from CSV or spreadsheets.

Install into Claude Code · Skills CLI (Vercel) All methods and platforms ↓
npx skills add crustdata/skills --skill email-enrichment -a claude-code
Open on GitHub

What email-enrichment does

This skill matches email addresses to contact profiles containing names, titles, and companies. It can also run in reverse to find business emails, personal addresses, and phone numbers for known contacts. Operations run against Crustdata Code Mode MCP through a six-phase waterfall, starting with free domain lookups in company_identify before moving to paid tools like person_enrich and web search.

Execution happens inside plain JavaScript scripts passed to execute({ code }). Type annotations break the whole run. Do not put : Type, as, or interface keywords into the generated code, because the engine rejects TypeScript syntax.

The skill parses CSV files, .xlsx spreadsheets, or inline text. It sorts emails into work, edu, and personal buckets, extracts names from prefixes, and uses commit history when hunting for technical talent.

What it helps with

  • Turn email CSV lists into contact profiles with company details
  • Find business emails and phone numbers for specific individuals
  • Resolve company details from email domains without credit charges
  • Track developer contact info using commit history fallbacks

Example prompts for the agent

  • Here's a CSV of 500 emails from our calendar events. Who are these people?
  • Identify the people behind these personal Gmail addresses
  • Find business emails and phone numbers for these 10 investors

How to install email-enrichment

1Skills CLI (Vercel)

npx skills add crustdata/skills --skill email-enrichment -a claude-code

2Manually: put the files in place

~/.claude/skills/email-enrichment/SKILL.md

Copy the whole skill folder; for one project use .claude/skills/email-enrichment/

Step by step, with commands for each platform: How to install skills in Claude Code: five ways

Questions about email-enrichment

How do I install email-enrichment in Claude Code?

Run npx skills add crustdata/skills --skill email-enrichment -a claude-code. Alternatively, place the skill file at ~/.claude/skills/email-enrichment/SKILL.md.

Which Crustdata tools does the skill call?

It relies on company_identify, person_enrich, batch_person_identify, person_search, web_search_live, and web_enrich_live. Calls follow a tiered order from free and low-cost methods to live web enrichment.

Why are TypeScript annotations prohibited in execution scripts?

The execute({ code }) runner accepts plain JavaScript only. Annotations such as : Type, interface, or as cause syntax parse errors that fail the entire job.

Source text

SKILL.md · MIT
---
name: email-enrichment
description: >
  Enrich a list of email addresses to find the person behind each one (email to person profile).
  Uses a six-phase waterfall over the Crustdata Code Mode MCP, calling these tools inside execute()
  scripts: company_identify, person_enrich (business_emails) / batch_person_identify, person_search
  (name+company and name-only strategies), web_search_live, web_enrich_live.
  Handles verification, progress saving, and resume.
  Trigger on: "enrich these emails", "who are these people", "find info for these email addresses",
  "look up these contacts", "identify people from emails", "reverse email lookup", "email to profile",
  or when someone provides a list/CSV/spreadsheet of email addresses wanting contact info.
display-name: Email Enrichment
category: sales-gtm
icon: mail-search
summary: "Turn emails into contact profiles, or turn a list of people into verified emails and phones."
sample-prompts:
  - "Here's a CSV of 500 emails from our calendar events. Who are these people?"
  - "Find business emails for these 10 investors"
  - "Identify the people behind these personal Gmail addresses"
---

# Email Enrichment

Two directions, one skill:

1. **Email to person** - Turn a list of email addresses into rich contact profiles (name, title, company, profile URL). Uses a six-phase waterfall optimized for coverage and accuracy.
2. **Person to email** - Find business emails, personal emails, and phone numbers for a list of people. Uses enrichment with personal contact info, plus a commit-history fallback for technical people.

---

## Overview

The approach uses six phases in a strict waterfall. Each phase catches emails that earlier phases missed. The phases are ordered by cost (free first, then cheapest) and reliability (highest precision first).

All Crustdata work runs inside `execute({ code })` scripts — write a short plain-JavaScript script whose only I/O is `const r = await callTool("<tool>", params)`, then branch on `r.ok`. Author it against the TypeScript-typed tool surface from `get_schema`, but put NO type annotations in the body — a `: Type`, `as`, or `interface` is a parse error that fails the whole run. See the per-phase scripts below.

| Phase | Crustdata tool (via `callTool`) | Targets | Cost |
|-------|----------|---------|------|
| 1 | `company_identify` | Work + Edu emails | FREE |
| 2 | `person_enrich` (`business_emails`) + post-verification | Work + Edu | ~1-2 cr/profile |
| 2 (personal) | `batch_person_identify` (async) | Personal | 1 cr/matched |
| 3 | `person_search` (name+company) | Missed work/edu | ~0.03 cr/result |
| 4 | `person_search` (name only) | Remaining personal | ~0.03 cr/result |
| 5 | `web_search_live` + `person_enrich` | ALL remaining | 1 cr/query + enrich |
| 6 | Scoring gate | ALL candidates from Phases 3-5 | N/A |

**Coverage rates:**

| Category | Person Match | Company Match |
|----------|-------------|---------------|
| Work emails | 95%+ | 95%+ |
| Edu emails | 95%+ | 95%+ |
| Personal emails | 95%+ | N/A |
| **Blended** | **95%+** | **95%+** |

---

## Phase 0: Parse input and classify emails

### Read the input

Accept CSV files, spreadsheets (.xlsx/.csv), or inline lists. Extract all email addresses. Deduplicate.

### Classify each email into one of three categories

**Personal email domains** (match against this list):
```
gmail.com, yahoo.com, hotmail.com, outlook.com, aol.com, icloud.com, me.com,
live.com, protonmail.com, proton.me, msn.com, ymail.com, comcast.net, att.net,
verizon.net, mac.com, fastmail.com, hey.com, pm.me, zoho.com, gmx.com,
googlemail.com
```

**Edu email domains** (match against these TLD patterns):
```
.edu, .ac.uk, .ac.jp, .ac.kr, .ac.in, .ac.nz, .ac.za
```

**Work emails**: everything else.

### Name extraction from email prefix

Split the local part (before `@`) on dots, underscores, and hyphens. Remove any parts that are purely digits. Capitalize each remaining part. Only keep parts with 2+ characters.

```python
import re

def extract_name_parts(email):
    local = email.split("@")[0]
    parts = re.split(r'[._\-]', local)
    parts = [p for p in parts if not p.isdigit()]
    parts = [p.capitalize() for p in parts if len(p) >= 2]
    return parts

# Examples:
# "[email protected]"   -> ["Daniel", "Lee"]
# "[email protected]"      -> ["John", "Smith"]
# "[email protected]"      -> ["Jsmith"]
# "[email protected]"  -> ["Rodriguez"]
```

---

## Phase 1: Company Identify (FREE)

Identify the company behind each non-personal email domain. This phase is FREE and should always run first.

### Script

`company_identify` takes a `domains` array, so deduplicate domains first (a list of 1,000 work emails might only have 200 unique domains) and resolve them all in ONE call.

```ts
// model query: identify the company behind each work/edu email domain
const domains = [...new Set(workEduEmails.map(e => e.split("@")[1].toLowerCase()))];
const r = await callTool("company_identify", { domains });
if (!r.ok) return { error: r.message };

// build domain_map: domain -> { name, primary_domain, professional_network_url }
const domainMap = {};
for (const rec of r.data) {
  const info = rec.matches?.[0]?.company_data?.basic_info;
  if (info) domainMap[rec.matched_on.toLowerCase()] = {
    name: info.name,
    primary_domain: info.primary_domain,
    professional_network_url: info.professional_network_url,
  };
}
return { domainMap };
```

### What it returns

An ARRAY of `{ matched_on, match_type, matches: [{ confidence_score, company_data }] }`. Read the company off `matches[0].company_data.basic_info` (`name`, `primary_domain`, `website`, `professional_network_url`, `description`, `year_founded`, …). `matched_on` is the domain you passed in, so you can map results back to inputs directly.

### How to run it

Store the result in a domain_map: `domain -> { name, ... }`. This `domainMap` is returned and passed back into later phases (via `inputs`) for Phase 3 verification — don't re-run this free call per-domain.

### Expected results

- 95%+ of work email domains will be identified
- Edu domains are nearly 100% (universities are well-known)

---

## Phase 2: Person Enrich via business email (work/edu)

Look up each work/edu email directly using person enrichment. Phase 2 branches based on email type:

- **Work/edu emails** -> use the `business_emails` identifier + post-verification (Branch A below)
- **Personal emails** -> see Branch B — the async `batch_person_identify` path, with the name-based phases as fallback

### Branch A: Work/edu emails (business_emails)

#### Script

`person_enrich` takes a `business_emails` ARRAY (≤25 per call), so batch the work/edu emails. Pass `fields: ["basic_profile", "experience"]` to get the name + employers needed for post-verification. Each call returns one record per input email; map results back via `matched_on`.

```ts
// model query: enrich work/edu emails to find the person behind each
const r = await callTool("person_enrich", {
  business_emails: workEduEmailBatch,          // ≤25 emails
  fields: ["basic_profile", "experience"],     // name + current/past employers
});
if (!r.ok) return { error: r.message };

// r.data is an ARRAY of { matched_on, match_type, matches: [{ confidence_score, person_data }] }
const byEmail = {};
for (const rec of r.data) {
  const pd = rec.matches?.[0]?.person_data;
  if (pd) byEmail[rec.matched_on.toLowerCase()] = pd;   // post-verify before accepting
}
return { byEmail };
```

For more than 25 work/edu emails, `chunk(emails, 25)` then `await parallelMap(batches, b => callTool("person_enrich", { business_emails: b, fields: ["basic_profile", "experience"] }))`.

#### Critical details

- `business_emails` takes an **array of email strings** (≤25 per call)
- Pass ONE identifier kind per call — `professional_network_profile_urls` and `business_emails` are not combined in the same call
- Despite the name "business_emails", this works for edu emails too (especially faculty/staff)
- Returns `person_data` including: `basic_profile.name`, `basic_profile.headline`, `social_handles.professional_network_identifier.profile_url`, and `experience.employment_details.current[]` / `.past[]` (employers)
- Set `preview: true` for a 0-credit base-profile check before paying

If a match is returned (`matches[0].person_data` has a name), it MUST pass post-verification before accepting (see Post-verification section below).

### Branch B: Personal emails (reverse personal-email lookup)

> **Async only:** Personal emails (gmail/yahoo/outlook) resolve through the ASYNC
> `batch_person_identify` — the only tool that matches personal emails. The sync tools cannot:
> `person_enrich` accepts only `professional_network_profile_urls` or `business_emails` as
> identifiers, `person_enrich_live` accepts only profile URLs, and `person_search` has no
> `emails` filter column.
>
> **How:** submit `batch_person_identify({ emails: [...] })` (≤300 per job), poll
> `batch_job_get(batch_id)` until `completed`, then read the `batch_results` rows —
> `matches[0].person_data` carries the same ids/basic_profile/profile-URL shape as Branch A.
> 1 credit per MATCHED identifier; unmatched identifiers are free. For emails it does not match,
> fall through to:
> 1. **Phase 4** — `person_search` by the full name extracted from the email prefix (works when
>    the prefix is a clear `first.last` pattern), then `person_enrich` by the resulting profile URL.
> 2. **Phase 5** — `web_search_live({ query: "who is EMAIL", sources: ["ai"] })` to resolve the
>    owner's name, then `person_search` → `person_enrich`.

### Post-verification (required for every Phase 2 Branch A result -- work/edu only)

The person enrich API can return wrong matches: a person at the right company but not the email owner, or a person who no longer works there. Every result must pass these checks. The checks run on the `person_data` returned by the enrich script above — employers live at `experience.employment_details.current[]` / `.past[]` (each entry has `name` for the company, `title`, and `company_website_domain`).

**Check 1: Employer domain verification.** The email domain must appear in the person's current OR past employer website domains. For example, `[email protected]` must have `sapphireventures.com` in at least one employer's domain list. If the domain doesn't appear in any employer (current or past), REJECT the match.

```python
def verify_employer_domain(person_data, email_domain):
    domain_base = email_domain.lower().split('.')[0]
    emp_details = person_data.get("experience", {}).get("employment_details", {})
    employers = emp_details.get("current", []) + emp_details.get("past", [])
    for emp in employers:
        domains = emp.get("company_website_domain") or []
        if isinstance(domains, str):
            domains = [domains]
        for d in domains:
            if email_domain.lower() in d.lower() or d.lower() in email_domain.lower():
                return True, emp.get("name", "")
        if len(domain_base) > 3 and domain_base in emp.get("name", "").lower().replace(" ", ""):
            return True, emp.get("name", "")
    return False, None
```

**Check 2: Name-prefix match.** The email prefix must plausibly match the returned person's name. For example, `[email protected]` should match a name containing "alling" (as in "Ted Alling"), not "Santosh Sankar". Check if any part of the profile name starts with the same characters as the email prefix, or if a first-initial + lastname pattern matches.

```python
def name_matches_prefix(profile_name, email_prefix):
    pn_parts = profile_name.lower().split()
    prefix = email_prefix.lower()
    for part in pn_parts:
        if prefix.startswith(part[:3]) or part.startswith(prefix[:3]):
            return True
        # First-initial + lastname pattern: "talling" = "t" + "alling"
        if len(prefix) > 2:
            for i in range(1, min(3, len(prefix))):
                if prefix[i:] in part and len(prefix[i:]) > 2:
                    return True
    return False
```

**Check 3: AI correction for name mismatches.** When the employer domain matches but the name doesn't (right company, wrong person), use `web_search_live` in deep research mode (`sources: ["ai"]`) to find who actually owns the email:

```ts
// model query: who actually owns [email protected]
const r = await callTool("web_search_live", {
  query: "who is [email protected]",
  sources: ["ai"],
});
const aiText = r.ok ? r.data.results?.map((x) => x.snippet || x.title).join(" ") : "";
return { aiText };
```

The AI response typically says something like "belongs to Ted Alling, Partner at Lamp Post Group". Extract the real name and search person DB (Phase 3 `person_search`) with the corrected name + company.

This step recovered 7 correct matches in testing that would otherwise have been lost.

### Expected results

- **Work/edu (Branch A):** ~58% of work+edu emails pass all verification checks. ~5% are rejected by employer domain check (wrong person entirely). ~1% are AI-corrected (right company, wrong person -> AI finds the real name).
- **Personal (Branch B):** matched directly by the async `batch_person_identify`; emails it does not match are resolved downstream via the name-based Phases 4/5, with precision gated by Phase 6.

---

## Phase 3: Person search by name + company

For work/edu emails that Phase 2 missed, try a name+company search. Extract a name guess from the email prefix and combine it with the company identified in Phase 1.

### When to use

Only for emails where:
1. Phase 2 returned no match
2. The email is work or edu (not personal)
3. The domain was identified in Phase 1 (we know the company name)
4. At least one name part can be extracted from the email prefix

### Script

The filter key is always `field`. Person-name filters on `basic_profile.name`; current-employer name filters on `experience.employment_details.current.company_name`; `limit` caps the page size.

```ts
// model query: find FirstName at CompanyName
const r = await callTool("person_search", {
  filters: { op: "and", conditions: [
    { field: "basic_profile.name", type: "(.)", value: "FirstName" },
    { field: "experience.employment_details.current.company_name", type: "(.)", value: "CompanyName" },
  ]},
  limit: 3,
  fields: ["basic_profile", "social_handles"],   // name + profile_url
});
if (!r.ok) return { error: r.message };
// r.data.profiles[]: read basic_profile.name and
// social_handles.professional_network_identifier.profile_url
return { profiles: (r.data.profiles ?? []).map((p) => ({
  name: p.basic_profile?.name,
  profile_url: p.social_handles?.professional_network_identifier?.profile_url,
})) };
```

### Verification (required)

The returned profile's name must contain the first name extracted from the email prefix:

```python
def verify_name_match(email_name_parts, profile_name):
    if not email_name_parts or not profile_name:
        return False
    return email_name_parts[0].lower() in profile_name.lower()
```

### How to run it

For each missed work/edu email, extract the name and look up the company from the domain_map:

```ts
// model query: find Kyle at Backswing Ventures (email [email protected])
// Name parts: ["Kyle"]; company from Phase 1 domainMap: "Backswing Ventures"
const r = await callTool("person_search", {
  filters: { op: "and", conditions: [
    { field: "basic_profile.name", type: "(.)", value: "Kyle" },
    { field: "experience.employment_details.current.company_name", type: "(.)", value: "Backswing Ventures" },
  ]},
  limit: 3,
  fields: ["basic_profile", "social_handles"],
});
return r.ok ? { profiles: r.data.profiles } : { error: r.message };
```

Check each returned profile: does "kyle" appear in `basic_profile.name`? If yes, it's a match.

### Expected results

- Catches emails that Phase 2 missed using name + company compound search
- Works best for emails with clear name formats (john.smith@, daniel_lee@)

---

## Candidate verification (CRITICAL -- do not skip)

Without verification, name/substring matches produce false positives. For example, a search for "wraecca" might match "Alessandro Racca" because "racca" is a substring.

**For every candidate, verify ALL of the following:**

**Step 1 -- Name verification (required for all email types):**
- Extract name parts from the email prefix
- If 2+ name parts: BOTH first AND last must appear in the profile name
- If 1 name part: that part must appear in the profile name, and the part must be 3+ characters

**Step 2 -- Organization verification (required for work and edu emails):**
- Look up the company/institution from the domain_map (Phase 1)
- Extract significant words from the org name (skip common words like "the", "inc", "llc", "of")
- At least one significant org word must appear somewhere in the profile data (check employers, education, headline)
- If no org word matches, reject the result even if the name matched

**Step 3 -- Personal emails (name check only):**
- For personal emails (gmail, yahoo, etc.), there is no org to cross-reference
- The name match from Step 1 is the only gate
- This means personal email matches have lower precision

A search profile carries its name at `basic_profile.name`; flatten that before verifying.

```python
def verify_candidate_match(email, name_parts, profile, domain_map):
    profile_name = profile.get("basic_profile", {}).get("name", "").lower()

    # Name verification
    if len(name_parts) >= 2:
        if not (name_parts[0].lower() in profile_name and name_parts[-1].lower() in profile_name):
            return False
    elif len(name_parts) == 1 and len(name_parts[0]) > 2:
        if name_parts[0].lower() not in profile_name:
            return False
    else:
        return False

    # Org verification for work/edu
    domain = email.split("@")[1]
    company_info = domain_map.get(domain)
    if company_info:
        org_name = company_info.get("name", "")
        skip = {"the", "inc", "llc", "ltd", "co", "corp", "of", "and", "for", "university", "college"}
        org_words = [w.lower() for w in org_name.split() if w.lower() not in skip and len(w) > 2]
        profile_str = str(profile).lower()
        if org_words and not any(w in profile_str for w in org_words):
            return False

    return True
```

### Expected results

- For work/edu emails: both name AND company must verify. No company from Phase 1 = automatic reject.
- For personal emails: name match only (lower precision, but better than no verification)

---

## Phase 4: Person search by name for personal emails

Last resort for personal emails where we can extract a plausible full name from the email prefix.

### When to use

Only for emails where:
1. All previous phases returned no match
2. The email is personal (gmail, yahoo, etc.)
3. At least 2 name parts can be extracted from the email prefix

### Script

```ts
// model query: find FirstName LastName by name
const r = await callTool("person_search", {
  filters: { op: "and", conditions: [
    { field: "basic_profile.name", type: "(.)", value: "FirstName LastName" },
  ]},
  limit: 5,
  fields: ["basic_profile", "social_handles"],
});
if (!r.ok) return { error: r.message };
return { profiles: r.data.profiles ?? [] };
```

### Acceptance criteria

- The search must return **3 or fewer results** (low ambiguity)
- If 4+ results come back, skip -- too many possible matches
- The returned name must reasonably match the extracted name parts

### How to run it

```ts
// model query: find Joanne Bradford (email [email protected]) by name
// Name parts: ["Joanne", "Bradford"]
const r = await callTool("person_search", {
  filters: { op: "and", conditions: [
    { field: "basic_profile.name", type: "(.)", value: "Joanne Bradford" },
  ]},
  limit: 5,
  fields: ["basic_profile", "social_handles"],
});
return r.ok ? { profiles: r.data.profiles } : { error: r.message };
```

If 1-3 results returned, take the first one. If 0 or 4+, mark as unmatched.

### Expected results

- Catches remaining personal emails with clear first.last patterns
- The 3-result ceiling prevents matching the wrong person for common names
- MUST verify: both name parts from the email prefix must appear in the returned profile name. Do not just accept the first result.

---

## Phase 5: Web search fallback for all remaining unmatched emails

Final fallback for emails that all previous phases missed. Uses web search to find the person's profile URL, then enriches via that URL. This catches vanity domains (e.g., carolewainaina.com), personal brand domains, and any email not indexed in Crustdata's database.

### When to use

For any email that remains unmatched after Phases 1-5, regardless of category (work, edu, or personal).

### Step 1: Web search for profile URL

```ts
// model query: find a professional-network profile URL for EMAIL
const r = await callTool("web_search_live", {
  query: "EMAIL linkedin",
  sources: ["web"],
});
// r.data.results[]: { source, title, url, snippet, ... }
const hit = r.ok && (r.data.results ?? []).find((x) => /linkedin\.com\/in\//.test(x.url || ""));
return { profile_url: hit?.url ?? null };
```

Check the results for any URL containing `linkedin.com/in/`. If found, proceed to Step 3.

### Step 2: Deep research mode for the name (if Step 1 didn't find a profile URL)

```ts
// model query: who owns EMAIL
const r = await callTool("web_search_live", {
  query: "who is EMAIL",
  sources: ["ai"],
});
const aiText = r.ok ? (r.data.results ?? []).map((x) => x.snippet || x.title).join(" ") : "";
return { aiText };
```

The AI response often says something like "belongs to Carole Wamuyu Wainaina" or "associated with John Smith at Company X". Extract the person's name and search the person DB:

```ts
// model query: find <Extracted Name> by name
const r = await callTool("person_search", {
  filters: { op: "and", conditions: [
    { field: "basic_profile.name", type: "(.)", value: "Extracted Name" },
  ]},
  limit: 3,
  fields: ["basic_profile", "social_handles"],
});
// take social_handles.professional_network_identifier.profile_url off the best match
return r.ok ? { profiles: r.data.profiles } : { error: r.message };
```

If `person_search` returns a result with a profile URL (`social_handles.professional_network_identifier.profile_url`), proceed to Step 3.

### Step 3: Enrich via profile URL

```ts
// model query: enrich the resolved profile URL to get the person's details
const r = await callTool("person_enrich", {
  professional_network_profile_urls: ["PROFILE_URL_FROM_STEP_1_OR_2"],
  fields: ["basic_profile", "experience"],
});
// r.data[0].matches[0].person_data: basic_profile.name, basic_profile.headline, employers
return r.ok ? { person: r.data?.[0]?.matches?.[0]?.person_data } : { error: r.message };
```

### Expected results

- Catches vanity/personal domains (carolewainaina.com, first-last.com)
- Catches people not indexed by email but findable via web search
- Deep research mode is particularly effective at resolving "who owns this email" queries

---

## Phase 6: Scoring gate (applied to all candidates from Phases 3-5)

All candidate matches produced by Phases 3, 4, and 5 must pass through this scoring gate before being accepted. Phase 2 results are exempt — Branch A has its own employer-domain + name post-verification, and Branch B matches are keyed on the email itself (the API matched the identifier directly).

### Hard requirements (both must pass)

1. **name_sim > 0.8** -- The similarity between the name extracted from the email prefix and the candidate profile name must exceed 0.8. This prevents a perfect company match from compensating for a bad name match.
2. **combined_score > 0.7** -- The overall combined score (incorporating name similarity, company match, and any other signals) must exceed 0.7.

Phase 6 requires BOTH `name_sim > 0.8` AND `combined_score > 0.7`. This prevents a perfect company match from compensating for a bad name match. For example, finding someone at the right company whose name does not resemble the email prefix will be rejected even if the company match is perfect.

### When a candidate fails

If a candidate fails the scoring gate, it is rejected and the email continues to the next phase in the waterfall. If no phase produces a candidate that passes the gate, the email is marked UNMATCHED.

---

## Scaling and concurrency

Rate limiting is handled by the host inside `execute` — there are no manual RPM tables, sleeps, or backoff to manage. For large lists, scale by fanning out work inside ONE script:

- **Batch enrich.** `person_enrich` and `company_identify` take arrays — pass up to 25 emails/domains per call. `chunk(emails, 25)` then `await parallelMap(batches, b => callTool("person_enrich", { business_emails: b, fields: [...] }))`.
- **Parallelize independent calls** with `await parallelMap(items, fn)` — never `for … await`. Keep dependent steps (search → enrich) sequential; only parallelize WITHIN a stage.
- **Return the smallest projection** — only what you `return` reaches the model.

### Optimization: deduplicate domains in Phase 1

A list of 1,000 work emails might only have 200 unique domains. Always deduplicate domains before calling `company_identify` (which takes a `domains` array — resolve all uniques in one call).

---

## Progress saving and resumability

For large lists, save progress to a JSON file after each phase so the enrichment can resume if interrupted.

### Progress file format

```json
{
  "phase_completed": 3,
  "domain_map": {
    "acme.com": {"name": "Acme Corp"},
    "stanford.edu": {"name": "Stanford University"}
  },
  "results": {
    "[email protected]": {
      "name": "John Smith",
      "headline": "VP Engineering at Acme",
      "company": "Acme Corp",
      "method": "person_enrich"
    }
  },
  "unmatched_emails": ["[email protected]"]
}
```

### Resume logic

On start, check if a progress file exists. If it does, skip phases that are already complete and continue from where it left off.

---

## Output

### CSV output

Generate a CSV with these columns:

| Column | Description |
|--------|-------------|
| `email` | Original email address |
| `category` | `work`, `edu`, or `personal` |
| `person_name` | Full name of the person |
| `person_headline` | Job title / headline |
| `company_name` | Company or institution name |
| `profile_url` | Professional profile URL |
| `method` | Which phase found the match: `person_enrich`, `name+company`, `name_search`, `web_search` |

### Summary statistics

Print a summary at the end:

```
=== Email Enrichment Results ===
Total emails: {N}
  Work:     {W}  | Person: {P1} ({P1%})  | Company: {C1} ({C1%})
  Edu:      {E}  | Person: {P2} ({P2%})  | Company: {C2} ({C2%})
  Personal: {R}  | Person: {P3} ({P3%})  | Company: {C3} ({C3%})
  Overall:  {N}  | Person: {PT} ({PT%})  | Company: {CT} ({CT%})

Breakdown by method:
  Phase 2 (person_enrich):    {count}
  Phase 2B (batch_identify):  {count}
  Phase 3 (name+company):     {count}
  Phase 4 (name_search):      {count}
  Phase 5 (web_search):       {count}
```

---

## Decision flowchart

```
For each email:
|
+-- Classify: work / edu / personal
|
+-- Phase 1: Is it work or edu?
|   +-- Yes -> Extract domain -> company_identify({ domains: [...] })
|   |   +-- Found company? -> Store in domain_map
|   |   +-- Not found? -> Continue (no company info for this domain)
|   +-- No (personal) -> Continue to Phase 2 Branch B
|
+-- Phase 2: Branch by email type
|   |
|   +-- Work/edu (Branch A):
|   |   +-- person_enrich({ business_emails: [email], fields: ["basic_profile","experience"] })
|   |   +-- Found person? -> Post-verify (employer domain + name prefix + AI correction)
|   |   |   +-- Verified? -> DONE (method=person_enrich)
|   |   |   +-- Failed verification? -> Continue to Phase 3
|   |   +-- Not found? -> Continue to Phase 3
|   |
|   +-- Personal (Branch B): batch_person_identify({ emails: [...] }) -> poll batch_job_get -> batch_results
|       +-- Matched? -> DONE (method=batch_identify)
|       +-- Not matched? -> Continue to Phase 4 (name search) then Phase 5 (web search)
|
+-- Phase 3: Is it work/edu AND have company name AND name parts?
|   +-- Yes -> person_search(filters: basic_profile.name + experience.employment_details.current.company_name)
|   |   +-- Found + name verified? -> Phase 6 scoring gate -> DONE (method=name+company)
|   |   +-- Not found? -> Continue to Phase 4
|   +-- No -> Skip to Phase 4
|
+-- Phase 4: Is it personal AND has 2+ name parts?
|   +-- Yes -> person_search(filters: basic_profile.name="FirstName LastName")
|   |   +-- 1-3 results returned + name verified? -> Phase 6 scoring gate -> DONE (method=name_search)
|   |   +-- 0 or 4+ results? -> Continue to Phase 5
|   +-- No -> Continue to Phase 5
|
+-- Phase 5: Still unmatched? (any email type)
|   +-- web_search_live({ query: "EMAIL linkedin", sources: ["web"] })
|   |   +-- Found linkedin.com/in/ URL? -> person_enrich({ professional_network_profile_urls: [URL] }) -> Phase 6 scoring gate -> DONE
|   +-- No URL found? -> web_search_live({ query: "who is EMAIL", sources: ["ai"] })
|   |   +-- Extracted person name? -> person_search(name) -> get profile URL -> person_enrich -> Phase 6 scoring gate -> DONE
|   +-- Nothing found? -> UNMATCHED
|
+-- Phase 6: Scoring gate (applied to all candidates from Phases 3-5)
    +-- Requires BOTH: name_sim > 0.8 AND combined_score > 0.7
    +-- Pass? -> Accept match
    +-- Fail? -> Reject, continue to next phase or mark UNMATCHED
```

---

## Key learnings

1. **Verification is non-negotiable.** In testing on 1,476 emails, strict verification removed 149 false positives that the unverified approach would have returned. Always verify.

2. **Phase 2 post-verification catches ~5% bad matches.** The person enrich API sometimes returns the wrong person at the right company (e.g., a different employee). Employer domain + name-pr...

© crustdata · MIT · crustdata/skills

Similar skills

Everything for Marketing and content →

From the same set6

Project sponsors

Companies that keep this analytics open
Gold sponsor

Impressions, clicks and code copies — open statistics. We keep the numbers public for the same reason NeBlask itself is public. Become a sponsor