[{"data":1,"prerenderedAt":509},["ShallowReactive",2],{"blog-how-to/clean-contact-list-before-launch":3},{"id":4,"title":5,"body":6,"category":476,"date":477,"dateModified":478,"description":479,"draft":480,"extension":481,"faq":482,"featured":480,"keywords":492,"meta":493,"navigation":494,"ogDescription":495,"ogTitle":496,"path":497,"readTime":498,"schemaOrg":499,"schemaType":500,"seo":501,"sitemap":502,"stem":503,"tags":504,"twitterCard":507,"__hash__":508},"blog/blog/how-to/clean-contact-list-before-launch.md","Clean Your Email List Before Launch: 6 Checks to Run",{"type":7,"value":8,"toc":465},"minimark",[9,18,29,47,52,55,65,72,85,91,117,121,135,143,146,150,161,171,177,199,202,208,215,221,225,233,236,242,249,253,258,264,271,274,296,305,309,332,338,341,345,351,354,385,396,409,431,437,440],[10,11,12,13,17],"p",{},"You have 412 contacts and one launch email to send. Somewhere in that list are two rows that will both deliver to the same person, a handful of ",[14,15,16],"code",{},"info@"," addresses that will get a \"hey, saw you signed up\" greeting, and at least one signup from four months ago who has forgotten your product exists. None of those will show up as an error. They show up as a reply that starts \"you sent me this twice.\"",[10,19,20,21,24,25,28],{},"The cleaning has to happen in the exported CSV, not in the dashboard, and for a specific reason: OperatorStack has no delete endpoint for contacts. The ",[14,22,23],{},"Contact"," model carries a ",[14,26,27],{},"deleted_at"," column and every query filters on it, but no route ever writes to it. Your contact list is append-only from your side. That is fine, as long as you know it before you plan your send.",[30,31,32],"tldr",{},[10,33,34,35,38,39,42,43,46],{},"Export your contacts, then run six checks on the CSV: case-variant duplicates, plus-address collisions, role addresses, source drift, stale signups, and the referral column. OperatorStack already guarantees no exact duplicate emails through a unique constraint on ",[14,36,37],{},"(project_id, email)",", so skip that check. The one it cannot catch is case: ",[14,40,41],{},"Dan@example.com"," and ",[14,44,45],{},"dan@example.com"," are two separate contacts.",[48,49,51],"h2",{"id":50},"start-with-the-export-because-it-is-your-only-editable-copy","Start with the export, because it is your only editable copy",[10,53,54],{},"In the dashboard, open Contacts and export. The CSV has exactly six columns:",[56,57,62],"pre",{"className":58,"code":60,"language":61},[59],"language-text","email,name,source,referral_code,referrals_count,created_at\n","text",[14,63,60],{"__ignoreMap":64},"",[10,66,67,68,71],{},"That is the whole surface you get to work with. Tags are not in it, and neither is ",[14,69,70],{},"invited_at"," or last activity, even though the dashboard list shows them. The export is capped at 10,000 rows and sorted newest first, so under 10,000 contacts you have everyone.",[10,73,74,75,42,78,81,82,84],{},"You can filter the export before you download it. Both ",[14,76,77],{},"source",[14,79,80],{},"search"," are supported as query parameters, and ",[14,83,80],{}," matches against email or name:",[56,86,89],{"className":87,"code":88,"language":61},[59],"/v1/projects/{project_id}/signups/export?source=waitlist\n/v1/projects/{project_id}/signups/export?search=%40acme.com\n",[14,90,88],{"__ignoreMap":64},[92,93,94],"info-box",{},[10,95,96,97,99,100,104,105,108,109,112,113,116],{},"The ",[14,98,80],{}," filter is a partial match on email ",[101,102,103],"strong",{},"or"," name, so searching ",[14,106,107],{},"acme"," will also match a contact named \"Acme Ops\" with a Gmail address. For domain checks, search the ",[14,110,111],{},"@"," too (URL-encoded as ",[14,114,115],{},"%40",") to keep it anchored to the email.",[48,118,120],{"id":119},"check-1-skip-the-duplicate-email-check-entirely","Check 1: skip the duplicate-email check entirely",[10,122,123,124,127,128,131,132,134],{},"This is the one most founders spend time on, and it is already done. The ",[14,125,126],{},"contacts"," table has a unique constraint named ",[14,129,130],{},"uq_contact_project_email"," on ",[14,133,37],{},", and the signup path looks for an existing contact on that pair before it inserts anything:",[56,136,141],{"className":137,"code":139,"language":140,"meta":64},[138],"language-python","result = await session.execute(\n    select(Contact).where(\n        Contact.project_id == project_id,\n        Contact.email == email,\n    )\n)\ncontact = result.scalar_one_or_none()\n","python",[14,142,139],{"__ignoreMap":64},[10,144,145],{},"If that returns a row, the existing contact is reused. Two identical addresses cannot exist in one project, no matter how many times somebody submits your form. Do not write a dedupe pass for it.",[48,147,149],{"id":148},"check-2-the-duplicate-it-will-not-catch","Check 2: the duplicate it will not catch",[10,151,152,153,156,157,160],{},"Look closely at that query. It compares ",[14,154,155],{},"Contact.email == email"," against the stored string. There is no ",[14,158,159],{},".lower()"," anywhere in the path.",[10,162,163,164,167,168,170],{},"Incoming emails are validated as a Pydantic ",[14,165,166],{},"EmailStr",", which normalizes the domain but leaves the part before the ",[14,169,111],{}," exactly as typed. Run it and you can see the split:",[56,172,175],{"className":173,"code":174,"language":61},[59],"'Dan@Example.com'  ->  'Dan@example.com'\n'dan@example.com'  ->  'dan@example.com'\n'DAN@EXAMPLE.COM'  ->  'DAN@example.com'\n",[14,176,174],{"__ignoreMap":64},[10,178,179,180,42,183,186,187,190,191,194,195,198],{},"Three submissions, three different stored strings, three separate contacts, all delivering to one inbox. The domain got lowercased so ",[14,181,182],{},"Example.com",[14,184,185],{},"example.com"," collapse correctly, but ",[14,188,189],{},"Dan",", ",[14,192,193],{},"dan",", and ",[14,196,197],{},"DAN"," do not.",[10,200,201],{},"This is the check that actually earns its time. Lowercase the whole address and count:",[56,203,206],{"className":204,"code":205,"language":140,"meta":64},[138],"import csv\nfrom collections import defaultdict\n\ngroups = defaultdict(list)\nwith open(\"contacts.csv\") as f:\n    for row in csv.DictReader(f):\n        groups[row[\"email\"].lower()].append(row)\n\nfor key, rows in groups.items():\n    if len(rows) > 1:\n        print(key, [r[\"email\"] for r in rows])\n",[14,207,205],{"__ignoreMap":64},[10,209,210,211,214],{},"Keep the row with the earliest ",[14,212,213],{},"created_at"," (it holds the referral history) and drop the rest from your send list.",[216,217,218],"warning-box",{},[10,219,220],{},"Do not \"fix\" this by re-submitting the lowercase version through your signup form. That creates a fourth contact rather than merging the first three, and the new one starts with a fresh referral code and zero referrals.",[48,222,224],{"id":223},"check-3-plus-addresses-that-land-in-one-inbox","Check 3: plus addresses that land in one inbox",[10,226,227,42,230,232],{},[14,228,229],{},"dan+launch@example.com",[14,231,45],{}," are genuinely different addresses, and OperatorStack is right to store them separately. Gmail and most other providers still deliver both to Dan.",[10,234,235],{},"Strip the tag before you compare, but treat the result as a flag rather than a merge, because plus-addressing is not universal:",[56,237,240],{"className":238,"code":239,"language":140,"meta":64},[138],"def inbox_key(email):\n    local, _, domain = email.lower().partition(\"@\")\n    return local.split(\"+\")[0] + \"@\" + domain\n",[14,241,239],{"__ignoreMap":64},[10,243,244,245,248],{},"Group on ",[14,246,247],{},"inbox_key"," and eyeball the collisions. On a pre-launch list this is usually two or three rows, often your own test signups, which is the other thing this check catches.",[48,250,252],{"id":251},"check-4-read-the-source-column-correctly-before-you-segment","Check 4: read the source column correctly before you segment",[10,254,96,255,257],{},[14,256,77],{}," column is not the first way somebody reached you. It is the highest-intent way, and it gets overwritten:",[56,259,262],{"className":260,"code":261,"language":140,"meta":64},[138],"_SOURCE_PRIORITY = {\"unknown\": 0, \"contact_form\": 1, \"form\": 2, \"chat\": 2, \"waitlist\": 3}\n",[14,263,261],{"__ignoreMap":64},[10,265,266,267,270],{},"When an existing contact takes a higher-intent action, their source upgrades. It never downgrades. Someone who asked a question through chat in July and joined the waitlist in September reads as ",[14,268,269],{},"waitlist"," today, with no trace of the chat in that column.",[10,272,273],{},"Two consequences for your send:",[275,276,277,285],"ul",{},[278,279,280,281,284],"li",{},"Exporting ",[14,282,283],{},"?source=waitlist"," gives you everyone whose strongest action was the waitlist. That is the right list for a launch announcement.",[278,286,287,288,291,292,295],{},"Your ",[14,289,290],{},"source_breakdown"," counts are not a history of how people arrived. A ",[14,293,294],{},"contact_form"," count that dropped between two exports does not mean the form stopped working. Those people upgraded.",[297,298,299],"tip-box",{},[10,300,301,302,304],{},"If you want to segment on \"asked a real question\" rather than \"signed up\", the chat and form records still exist on the contact detail view. The ",[14,303,77],{}," column just stops reflecting them once the waitlist upgrade lands.",[48,306,308],{"id":307},"check-5-role-addresses-get-their-own-send","Check 5: role addresses get their own send",[10,310,311,190,313,190,316,190,319,190,322,190,325,190,328,331],{},[14,312,16],{},[14,314,315],{},"support@",[14,317,318],{},"hello@",[14,320,321],{},"admin@",[14,323,324],{},"sales@",[14,326,327],{},"billing@",[14,329,330],{},"contact@",". Flag them:",[56,333,336],{"className":334,"code":335,"language":140,"meta":64},[138],"ROLE = {\"info\", \"support\", \"hello\", \"admin\", \"sales\", \"billing\", \"contact\", \"team\"}\nrole_rows = [r for r in rows if r[\"email\"].split(\"@\")[0].lower() in ROLE]\n",[14,337,335],{"__ignoreMap":64},[10,339,340],{},"Then split rather than delete. A role address from a company that signed up is frequently a better lead than an individual Gmail account, but a \"hi Dan, I saw you joined\" first line reads badly when it lands in a shared inbox. Give them a version without the personal greeting.",[48,342,344],{"id":343},"check-6-age-out-the-signups-who-have-forgotten-you","Check 6: age out the signups who have forgotten you",[10,346,347,348,350],{},"Sort on ",[14,349,213],{}," and look at anyone older than about 90 days. They are not spam and they did opt in, but a cold contact who has heard nothing since June is where your bounce and complaint rates come from.",[10,352,353],{},"They do not need removing. They need a different first line, one that says when they signed up and what the product is, instead of assuming they remember. On a pre-launch list this segment is usually small enough to be worth the ten minutes.",[355,356,359,363,366,369],"stat-box",{"label":357,"number":358},"columns in the export, and no delete button","6",[48,360,362],{"id":361},"what-to-do-with-the-cleaned-file","What to do with the cleaned file",[10,364,365],{},"You now have a CSV that is shorter than the one you exported and split into two or three segments. That file is what goes into your email tool. Nothing you did changes the OperatorStack side, and it does not need to: your contact list stays complete and append-only, and your send list is the filtered view of it.",[10,367,368],{},"Tags are worth setting up if you expect to do this again. Creating a tag and applying it to a contact are both real endpoints, so you can mark the role addresses and the case-duplicates once rather than rediscovering them every launch. They are not part of the CSV export, so they are a dashboard aid rather than a filter you can pull, but they survive between sends.",[370,371,372],"faq-section",{},[373,374,376],"faq-item",{"question":375},"Does OperatorStack already remove duplicate emails?",[10,377,378,379,381,382,384],{},"Exact duplicates, yes. The contacts table has a unique constraint on ",[14,380,37],{},", and the signup path looks for an existing contact on that pair before inserting, so the same address cannot appear twice in one project. What it does not catch is two addresses that differ only in the case of the part before the ",[14,383,111],{},", because the lookup compares the stored strings exactly and never lowercases them.",[373,386,388],{"question":387},"Can I delete a contact from the OperatorStack dashboard?",[10,389,390,391,24,393,395],{},"No. The ",[14,392,23],{},[14,394,27],{}," column and every query filters on it, but no API route writes to it, so there is no delete or soft-delete endpoint for contacts. Cleaning happens in the CSV you export and hand to your email tool, not in the dashboard.",[373,397,399],{"question":398},"How many contacts can I export at once?",[10,400,401,402,405,406,408],{},"10,000 rows. The export is capped by ",[14,403,404],{},"MAX_EXPORT_ROWS"," in the CSV export service and returns the newest contacts first, ordered by ",[14,407,213],{}," descending. Below 10,000 contacts you get everyone in one file.",[373,410,412],{"question":411},"Why does a contact show source waitlist when they first used the contact form?",[10,413,414,415,418,419,418,421,42,424,427,428,430],{},"Source upgrades on higher intent and never downgrades. The priority order is ",[14,416,417],{},"unknown",", then ",[14,420,294],{},[14,422,423],{},"form",[14,425,426],{},"chat"," tied, then ",[14,429,269],{},". When someone who already exists takes a higher-intent action, their source is overwritten. The column tells you their strongest signal, not their first one.",[373,432,434],{"question":433},"Should I remove role addresses like info@ before I send?",[10,435,436],{},"Segment them, do not delete them. A role address at a company that signed up is often a real buying signal, it just should not get the same personal first-line copy as an individual. Split them into their own send rather than dropping them from the list.",[10,438,439],{},"::",[441,442,443,444],"content-related-articles",{},"\n  ",[445,446,443,450],"contentrelatedcard",{"href":447,"title":448,"description":449},"/blog/how-to/export-waitlist-data","How to Export Your Waitlist Data","Get your contacts out as CSV, including the columns you get and the row cap.",[445,451,443,455],{"href":452,"title":453,"description":454},"/blog/guides/unified-contact-list","The Unified Contact List","How waitlist signups, form submissions, and chats collapse into one contact record.",[445,456,460],{"href":457,"title":458,"description":459},"/blog/how-to/sync-contacts-email-tool","Sync Contacts to Your Email Tool on a Nightly Schedule","Once the list is clean, keep it current: a nightly pull that sends only the contacts your ESP has not seen.",[461,462],"cta-box",{"href":463,"label":464},"/","Get Started Free",{"title":64,"searchDepth":466,"depth":466,"links":467},2,[468,469,470,471,472,473,474,475],{"id":50,"depth":466,"text":51},{"id":119,"depth":466,"text":120},{"id":148,"depth":466,"text":149},{"id":223,"depth":466,"text":224},{"id":251,"depth":466,"text":252},{"id":307,"depth":466,"text":308},{"id":343,"depth":466,"text":344},{"id":361,"depth":466,"text":362},"how-to","2026-09-21",null,"Before your launch email goes out, run six checks on your contact export: case-variant duplicates, plus-address collisions, role addresses, and source drift.",false,"md",[483,485,487,489,491],{"question":375,"answer":484},"Exact duplicates, yes. The contacts table has a unique constraint on (project_id, email), and the signup path looks for an existing contact on that pair before inserting, so the same address cannot appear twice in one project. What it does not catch is two addresses that differ only in the case of the part before the @, because the lookup compares the stored strings exactly and never lowercases them.",{"question":387,"answer":486},"No. The Contact model carries a deleted_at column and every query filters on it, but no API route writes to it, so there is no delete or soft-delete endpoint for contacts. Cleaning happens in the CSV you export and hand to your email tool, not in the dashboard.",{"question":398,"answer":488},"10,000 rows. The export is capped by MAX_EXPORT_ROWS in the CSV export service and returns the newest contacts first, ordered by created_at descending. Below 10,000 contacts you get everyone in one file.",{"question":411,"answer":490},"Source upgrades on higher intent and never downgrades. The priority order is unknown, then contact_form, then form and chat tied, then waitlist. When someone who already exists takes a higher-intent action, their source is overwritten. The column tells you their strongest signal, not their first one.",{"question":433,"answer":436},"clean email list before launch,remove duplicate emails,email list hygiene,contact list cleanup,launch email list",{},true,"Six checks to run on your contact export before the one send that matters, including the case-variant duplicate OperatorStack will not catch for you.","Clean Your Email List Before Launch","/blog/how-to/clean-contact-list-before-launch","7 min","[object Object]","HowTo",{"title":5,"description":479},{"loc":497},"blog/how-to/clean-contact-list-before-launch",[126,505,506],"email-list","launch","summary_large_image","XiFA3iM7TR5tQImdRrLPMy7ESBanYA-DdpcnL0HX7CM",1790937870387]