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 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."

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 Contact model carries a 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.

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 (project_id, email), so skip that check. The one it cannot catch is case: Dan@example.com and dan@example.com are two separate contacts.

Start with the export, because it is your only editable copy

In the dashboard, open Contacts and export. The CSV has exactly six columns:

email,name,source,referral_code,referrals_count,created_at

That is the whole surface you get to work with. Tags are not in it, and neither is 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.

You can filter the export before you download it. Both source and search are supported as query parameters, and search matches against email or name:

/v1/projects/{project_id}/signups/export?source=waitlist
/v1/projects/{project_id}/signups/export?search=%40acme.com

The search filter is a partial match on email or name, so searching acme will also match a contact named "Acme Ops" with a Gmail address. For domain checks, search the @ too (URL-encoded as %40) to keep it anchored to the email.

Check 1: skip the duplicate-email check entirely

This is the one most founders spend time on, and it is already done. The contacts table has a unique constraint named uq_contact_project_email on (project_id, email), and the signup path looks for an existing contact on that pair before it inserts anything:

result = await session.execute(
    select(Contact).where(
        Contact.project_id == project_id,
        Contact.email == email,
    )
)
contact = result.scalar_one_or_none()

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.

Check 2: the duplicate it will not catch

Look closely at that query. It compares Contact.email == email against the stored string. There is no .lower() anywhere in the path.

Incoming emails are validated as a Pydantic EmailStr, which normalizes the domain but leaves the part before the @ exactly as typed. Run it and you can see the split:

'Dan@Example.com'  ->  'Dan@example.com'
'dan@example.com'  ->  'dan@example.com'
'DAN@EXAMPLE.COM'  ->  'DAN@example.com'

Three submissions, three different stored strings, three separate contacts, all delivering to one inbox. The domain got lowercased so Example.com and example.com collapse correctly, but Dan, dan, and DAN do not.

This is the check that actually earns its time. Lowercase the whole address and count:

import csv
from collections import defaultdict

groups = defaultdict(list)
with open("contacts.csv") as f:
    for row in csv.DictReader(f):
        groups[row["email"].lower()].append(row)

for key, rows in groups.items():
    if len(rows) > 1:
        print(key, [r["email"] for r in rows])

Keep the row with the earliest created_at (it holds the referral history) and drop the rest from your send list.

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.

Check 3: plus addresses that land in one inbox

dan+launch@example.com and dan@example.com are genuinely different addresses, and OperatorStack is right to store them separately. Gmail and most other providers still deliver both to Dan.

Strip the tag before you compare, but treat the result as a flag rather than a merge, because plus-addressing is not universal:

def inbox_key(email):
    local, _, domain = email.lower().partition("@")
    return local.split("+")[0] + "@" + domain

Group on 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.

Check 4: read the source column correctly before you segment

The source column is not the first way somebody reached you. It is the highest-intent way, and it gets overwritten:

_SOURCE_PRIORITY = {"unknown": 0, "contact_form": 1, "form": 2, "chat": 2, "waitlist": 3}

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 waitlist today, with no trace of the chat in that column.

Two consequences for your send:

  • Exporting ?source=waitlist gives you everyone whose strongest action was the waitlist. That is the right list for a launch announcement.
  • Your source_breakdown counts are not a history of how people arrived. A contact_form count that dropped between two exports does not mean the form stopped working. Those people upgraded.

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 source column just stops reflecting them once the waitlist upgrade lands.

Check 5: role addresses get their own send

info@, support@, hello@, admin@, sales@, billing@, contact@. Flag them:

ROLE = {"info", "support", "hello", "admin", "sales", "billing", "contact", "team"}
role_rows = [r for r in rows if r["email"].split("@")[0].lower() in ROLE]

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.

Check 6: age out the signups who have forgotten you

Sort on created_at 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.

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.

6columns in the export, and no delete button

Can I delete a contact from the OperatorStack dashboard?

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.

How many contacts can I export at once?

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.

Why does a contact show source waitlist when they first used the contact form?

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.

Should I remove role addresses like info@ before I send?

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.

::