---
name: lead-list-cleaner
description: Cleans a cold email lead list before import - takes the company domain from the email address, dedupes by email and domain, drops or tags role accounts and free-mail, catches large companies by how many rows share one email domain, matches blocklists by brand stem, tags catch-all addresses, fixes ALL CAPS names and company suffixes, suppresses rows whose merge fields are empty or junk, and removes past contacts and unsubscribes. Returns a kept file, a dropped file with a drop_reason per row and a report with counts. Use when the user shares a CSV of leads, asks to clean, dedupe or prepare a list for a sequencer, or sees "Hi there" or broken names in sent emails.
---

# Lead list cleaner

You prepare a lead list so every row that reaches the sequencer is a real target with clean merge fields. You keep every removed row with a reason, so the user can review and reverse decisions.

## Step 1. Collect

Ask for:

1. The CSV (or its header and 20 sample rows) and the row count.
2. The ideal customer: company size, whether free-mail addresses (gmail.com and similar) are acceptable.
3. The merge fields the copy uses (for example `first_name`, `company`).
4. Suppression sources: past contacts, unsubscribes, bounces, current customers, competitors.
5. Whether the list was verified, and the verification status column.

## Step 2. Clean, in this order

1. **Normalize emails**: trim, lowercase, drop rows without a valid address.
2. **Domain from the email**: take the part after `@`. Do not trust a `domain` or `website` column: enrichment often puts a brand or parent-company site there while the address is on another domain.
3. **Dedupe**: one row per email. Then decide per domain: keep all contacts, or keep the best one per company if the user wants one contact per account.
4. **Role accounts**: drop `info@`, `sales@`, `support@`, `admin@`, `office@`, `hello@`, `contact@`, `noreply@` unless the user targets them on purpose.
5. **Free-mail**: drop or keep per the user's ideal customer.
6. **Large companies**: count rows per email domain inside the list. A domain with many rows is usually a large company even if the size column says otherwise. Flag domains above a threshold the user sets (for example 10 rows).
7. **Blocklist by brand stem**: match customers and competitors by the first label of the domain, so `brand.com`, `brand.co.uk` and `brand.de` all match.
8. **Catch-all addresses**: keep them, but tag them (`mailbox_confidence=catch_all`). Consider sending them from a separate, well-warmed group of mailboxes.
9. **Fix casing**: `JOHN` to `John`, `ACME PLUMBING INC` to `Acme Plumbing`. Strip legal suffixes (Inc, LLC, Ltd, GmbH, Corp) from the company name used in copy.
10. **Merge fields**: suppress rows where any field used in the copy is empty or junk (a job title in the company column, digits only, "N/A", "-"). A fallback like "there" is weaker than not sending.
11. **Suppression**: remove past contacts, unsubscribes, bounces and customers, matched by email and, for companies, by domain.

## Output format

1. `kept.csv`: cleaned rows, with added columns `email_domain`, `domain_rows`, `mailbox_confidence`.
2. `dropped.csv`: every removed row with `drop_reason` (one of: invalid_email, duplicate, role_account, free_mail, large_company, blocklist, empty_merge_field, junk_merge_field, suppressed).
3. A report: rows in, rows kept, rows dropped by reason, top 10 domains by row count, 10 sample kept rows rendered with the copy's first line.

## Optional script (Python, standard library only)

```python
import csv, re, collections
ROLE = {"info","sales","support","admin","office","hello","contact","noreply"}
SUFFIX = re.compile(r"[,\s]+(inc|llc|ltd|gmbh|corp|co)\.?$", re.I)
rows = list(csv.DictReader(open("leads.csv", newline="", encoding="utf-8")))
dom = lambda r: r["email"].strip().lower().split("@")[-1]
per_domain = collections.Counter(dom(r) for r in rows)
seen, kept, dropped = set(), [], []
for r in rows:
    e = r["email"].strip().lower()
    reason = None
    if not re.match(r"^[^@\s]+@[^@\s]+\.[a-z]{2,}$", e): reason = "invalid_email"
    elif e in seen: reason = "duplicate"
    elif e.split("@")[0] in ROLE: reason = "role_account"
    elif per_domain[dom(r)] > 10: reason = "large_company"
    elif not r.get("first_name", "").strip() or not r.get("company", "").strip(): reason = "empty_merge_field"
    seen.add(e)
    if reason: dropped.append({**r, "drop_reason": reason}); continue
    r["first_name"] = r["first_name"].strip().title()
    r["company"] = SUFFIX.sub("", r["company"].strip()).title() if r["company"].isupper() else SUFFIX.sub("", r["company"].strip())
    r["email_domain"], r["domain_rows"] = dom(r), per_domain[dom(r)]
    kept.append(r)
```

Extend it with the blocklist, free-mail and suppression steps. Title-casing can damage names like "McDonald" or "IBM"; review the sample.

## Rules

- Never delete silently: every removed row goes to `dropped.csv` with a reason.
- Fix data before import; many sequencers do not let you edit a lead after upload.
- After import, render 5 real leads in the sequencer's preview or a test send before launch.

## Example

Input: 5,000 rows, copy uses `first_name` and `company`.

Output (abridged): kept 4,120. Dropped: duplicate 210, role_account 180, large_company 290 (3 domains with 40+ rows each), empty_merge_field 160, suppressed 40. 610 catch-all rows tagged.
