I cleaned my email list and still could not tell you whether one address existed.
There was a moment when I nearly sent a mailing list without reading it line by line. Not because the list was short. Because every line looked fine.
info@acme.com — plausible. priya.nair@acme.co — plausible. casey@tempmail.com — plausible enough that my eye kept sliding past it.
I had been doing list hygiene by eye for years, and my eye is good at one thing: judging whether a string looks like an email address. That was not the question I needed answered. The question was which lines were worth sending to, and my eye had no opinion on that.
So I built ListClean around the gap: paste one address per line, get back the lines that are obviously not people, and a score for the rest.
What my eye was actually checking
The gap showed up the first time I ran my own list through it and it flagged things I had already "approved" by reading.
Three categories came back. One was syntax: lines that are not local@domain.tld at all — a missing @, a stray space, a full name pasted where an address should be. My eye had been catching those fine.
The second was role locals: info@, admin@, support@, sales@, contact@, hello@, team@, billing@ — 8 prefixes in the model layer's list. Valid addresses to a mail server, but not a person, and no reply lands with me.
The third was a short disposable-domain list: mailinator, tempmail, 10minutemail, guerrillamail, trashmail, yopmail — 6 domains. Those inboxes exist. They exist to be disposable.
Two of the three categories were about a known local part or domain, not malformed syntax. My eye checks one thing well. A tool can check three.
The three checks, and the code behind them
The deterministic layer is small enough to read in one sitting. This is the part of the rules module that does the work:
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/i const DISPOSABLE = /mailinator|guerrillamail|tempmail|10minutemail|trashmail|yopmail/i const ROLE = /^(info|admin|support|sales|contact|noreply|no-reply|office)@/i Syntax is the first line. A line needs something, an @, something, a dot, something — the shape RFC 5322 describes, minus the parts I do not need for triage.
The other two are substring and prefix tests against short lists, with no model in this layer. Same paste, same flags, every time.
The score is arithmetic on top of that: passed lines over pasted lines, as a percent. In the demo path it is literally Math.round((valid / raw.length) * 100). Paste 10 lines and 8 pass, the score reads 80%. Paste nothing, it reads 0%.
The ruleset carries 8 checks — EL-01 through EL-06 plus two honesty rules — and it is versioned: 2026-10-06. When I change what "role local" means, I want that change to be a diff in a file, not a shift in a prompt that differed on Tuesday.
Where my own two layers disagree
Here is the thing I did not plan.
The deterministic layer and the model layer do not carry the same role-local list. The regex above matches 8 prefixes: info, admin, support, sales, contact, noreply, no-reply, office. The model prompt lists 8: info, admin, support, sales, hello, contact, team, billing. 5 shared, 3 unique to each side.
So hello@ is flagged 1x by the model layer and 0x by the regex, noreply@ is the mirror image, and info@ is flagged 2x, once by each. Small difference, but the same pasted list can produce two different reports depending on which layer answered. That is a bug, not a design choice, and it is on my list to fix rather than document around.
The demo path is honest about what it is: the mock runs the same three checks locally and prints DEMO ONLY on its first line. A live run with no API key configured returns 503 instead of quietly falling back to the mock. I would rather have the error than a plausible-looking wrong answer.
The score is a count, not a verdict
Let me put the uncomfortable part plainly, because this is the part that gets misread.
80% means 8 of my 10 pasted lines were not flagged. It does not mean 80% of my list will be opened. The score summarizes what the checks did not catch, which is a statement about the checks, not about the mailboxes.
Two more limits. The ruleset flags a list that looks purchased or scraped — EL-06 watches for that wording — but it cannot tell me whether any individual on the list consented. Permission is not a property of a string. And the tool only knows the two short lists it carries, so a throwaway provider that is not on them reads as ordinary mail.
What ListClean is not
This part matters more than the feature list, so I will be exact.
It is a static syntax check on a pasted list, plus two short lookup lists. That is the whole mechanism.
It does not ping mailboxes. There is no SMTP handshake, no MX lookup, no catch-all probe.
It does not send campaigns and does not connect to your sending platform. The last step is copy the passed lines.
It does not check deliverability or reputation. It knows nothing about SPF, DKIM or DMARC alignment, or whether a mailbox has bounced you before.
It cannot confirm an address exists, and it does not look up spam traps.
It does not correct typos either. gmial.com is a well-formed domain, so a line using it passes. There is no typo database behind this.
Here is the symmetry worth keeping in mind. A pass means the line looks syntactically valid and is not on the two short lists I carry. The absence of a flag is not proof that an address works. Both halves are true, and the second is the one that gets people in trouble.
What I actually do with the output
I copy the passed lines into the sending tool I already use, and I send it myself. The sending platform stays mine.
The role-local lines are the ones I read twice. Some are mailboxes I would happily write to — a two-person company where hello@ is a real person reading it. I do not let the tool delete them. It gives me a reason to look.
For what this product cannot tell me, I use other tools: bounce history from the platform I send with, a deliverability check when I need one, and a permission basis for the list I can point at in writing.
It costs $19/mo, or $190/yr, and the whole job is one paste. If you want the same thing, ListClean is at that domain, and it will tell you only what its three checks can tell you.
FAQ
Q: Does ListClean check whether an address actually exists?
A: No. It runs a static syntax check on the lines you paste, plus two short lists — common role locals and a short disposable-domain list. Nothing is pinged, so it cannot confirm a mailbox exists, and no flag is not proof the address works.
Q: What does the score mean?
A: It is the share of pasted lines that passed those checks, as a percent. 8 of 10 lines passing reads as 80%. It describes what the checks did not catch, not how good the mailboxes are.
Q: Will it catch a typo in the domain?
A: No. gmial.com is a well-formed domain, so the syntax check passes it. There is no typo database and no mail-server lookup behind this product.
Q: Does it send my campaign or connect to my sending platform?
A: No. The output is a list you copy. Sending stays with the tool you already use.
Q: What happens to the lines it flags?
A: Nothing is deleted. Flagged lines are listed separately with the reason attached — syntax, role local, or disposable domain — and you decide what to do with each.
References
- RFC 5322 — Internet Message Format — the mailbox grammar the syntax check is a loose subset of.
- FTC — CAN-SPAM Act compliance guide for business — why a clean list is not automatically a sendable list.
- M3AAWG — Sender Best Common Practices, version 3 — the source behind the syntax and permission-minded rules.
- M3AAWG — position on selling email lists — why a list that passes a hygiene check still needs a permission basis.