listwash
Self-hosted email list verification. Wash a mailing list on your own machine — syntax checks, MX lookups, disposable-domain detection, and a polite SMTP mailbox probe with catch-all detection — instead of paying a per-address fee to upload your list to a third party.
- One dependency (dnspython). CSV in, CSV out, stdlib everywhere else.
- Nothing leaves your machine except the SMTP probe itself — no list ever gets uploaded anywhere.
- No mail is ever sent. The probe issues
RCPT TOand resets; it never sendsDATA, so no message is delivered and nobody gets a mysterious email. - Honest verdicts. Servers that block probes, greylist, or answer
ambiguously are reported as
unknown— never guessed intoinvalidso you don't prune real subscribers.
pip install listwash
Quickstart
Check a few addresses (--helo is the domain the probe identifies itself as —
use one you control):
listwash check ceo@example.com info@example.org --helo yourdomain.com
Wash a whole list:
listwash verify leads.csv --helo yourdomain.com
1988 rows, 1642 unique addresses, verifying 1642
probing 511 domains on port 25...
25/511 domains
...
-> leads_verified.csv
verdict breakdown:
valid 1103
invalid 201
catch-all 187
unknown 127
do_not_mail 24
mailable (valid + catch-all): 1290 / 1988
The output CSV is your input with five columns appended: verdict,
smtp_code, detail, flags, mx_domain. Everything else — column order,
extra columns, duplicate rows — is preserved.
No outbound port 25? Run the passive tiers only (no probe identity needed):
listwash verify leads.csv --no-smtp
How it works
Four tiers, cheapest first:
- Syntax — malformed addresses are
invalidwithout touching the network. - MX/DNS — the domain must resolve and have mail servers (falls back to
the RFC 5321 implicit-MX A record). Dead domains are
invalid. - Disposable & flags — throwaway-mail domains (8,000+ bundled) are
do_not_mail. Role addresses (info@,sales@, …) and free providers (gmail, outlook, …) are flagged but not failed. - SMTP probe — connect to the domain's best MX on port 25 and issue
RCPT TOfor each address, without ever sendingDATA.
Before trusting any per-address answer, tier 4 probes a control address — a random local part that cannot exist:
- Control accepted → the domain is catch-all; acceptance is meaningless,
every address there is marked
catch-all(deliverable but risky). - Control refused by policy or greylisted → the server won't talk to us
honestly; every address there is
unknown, not guessed. - Control rejected as unknown user → the server distinguishes mailboxes, so its per-address answers are trustworthy.
Verdicts
| verdict | meaning |
|---|---|
valid |
MX ok, server distinguishes recipients, mailbox accepted |
invalid |
bad syntax, dead domain / no MX, or a hard "user unknown" reject |
catch-all |
server accepts every recipient — deliverable but unverifiable, bounce risk |
unknown |
greylisted, timeout, probe blocked, or port 25 unreachable |
do_not_mail |
disposable domain — suppress regardless of deliverability |
The independent flags column may contain role, free, and/or
disposable — informational only, never a verdict.
What SMTP probing can and can't tell you
Every verifier that probes from a single IP — including the paid ones — hits the same walls. listwash tells you when it hit one instead of guessing:
- Gmail usually distinguishes mailboxes (
550 5.1.1for a bad address) but may greylist or rate-limit under volume →unknown. - Microsoft 365 / Outlook returns
550 5.4.1 Access deniedfor both unknown recipients and blocked probes — genuinely ambiguous, so listwash returnsunknownrather than wrongly prune a real address. - Servers that reject the probing IP by policy (
5.7.*, "access denied", "blocked") →unknown, neverinvalid. - Greylisting (
450/451) →unknown; re-run later —unknownverdicts are deliberately not served from cache on a later day.
What it is reliable for: killing dead domains, hard bounces, disposable and role addresses, and flagging catch-all domains as risky before they damage your sender reputation.
It cannot check SPF/DKIM/DMARC alignment or inbox placement — those are properties of your sending, not of the recipient address.
Requirements
-
Python ≥ 3.9.
-
Outbound TCP port 25 for the SMTP tier. Most residential ISPs and cloud providers (AWS, GCP, Azure, Hetzner by default) block it. Test yours:
python -c "import socket; socket.create_connection(('gmail-smtp-in.l.google.com', 25), 10); print('port 25 open')"If it's blocked,
--no-smtpstill gives you tiers 1–3 (dead domains, syntax, disposables) from anywhere. -
A domain you control for
--helo. The probe identifies itself with it; probing with a made-up identity gets you blocked and is rude.
CLI reference
listwash verify INPUT.csv [--out PATH] [--email-col NAME] [--limit N] [shared options]
listwash check EMAIL [EMAIL...] [shared options]
shared options:
--no-smtp passive tiers only (no port 25, no identity needed)
--helo DOMAIN probe HELO name (required unless --no-smtp)
--mail-from ADDR probe MAIL FROM (default: verify@<helo>)
--workers N domains probed in parallel (default 8)
--fresh ignore the verdict cache
--cache-days N max age for cached definitive verdicts (default 7)
--resolvers IP[,IP] DNS servers for MX lookups (default: system resolver;
useful when a home-router DNS mishandles MX queries)
--disposable-file P replace the bundled disposable-domain list
--timeout SEC SMTP connect timeout per MX (default 12)
--pause SEC delay between RCPTs to the same server (default 0.4)
The email column is auto-detected (common header names, else the first column
where most values contain @); use --email-col to override.
Library use
from listwash import verify
results = verify(
["ceo@example.com", "info@example.org"],
helo="yourdomain.com", # or smtp=False for passive-only
)
for email, r in results.items():
print(email, r["verdict"], r["detail"], r["flags"])
Caching
SMTP verdicts are cached per address (%LOCALAPPDATA%\listwash\ on Windows,
~/.cache/listwash/ elsewhere) so re-running a list is cheap. Definitive
verdicts are served for up to --cache-days days; unknown verdicts are
only served same-day, so a next-day re-run automatically retries greylisted
and blocked domains. --fresh ignores the cache entirely.
Responsible use
The probe is deliberately polite: one connection per domain, sequential RCPTs
with a delay, RSET between them, QUIT when done, and never DATA. Even
so, verify lists you have a legitimate reason to hold — cleaning your own
subscriber or CRM data. Don't use it to validate scraped or purchased lists
you have no right to mail, and don't crank the politeness settings down to
hammer other people's mail servers. Mail server operators can and do block
IPs that probe abusively — the conservative defaults are what keep this tool
working.
Data lists
disposable_domains.txt (8,000+ domains), free_providers.txt, and
role_prefixes.txt are bundled and easy to extend — one entry per line, #
comments. PRs adding domains welcome. The disposable list can also be swapped
at runtime (--disposable-file) for e.g. the CC0
disposable-email-domains
list.
License
MIT
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file listwash-0.1.0.tar.gz.
File metadata
- Download URL: listwash-0.1.0.tar.gz
- Upload date:
- Size: 67.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.14.5
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4047ad470230432e948168611cea3f4af652f19ccd38d30faf81b32402aa1dbf
|
|
| MD5 |
d760af52be2ad64ec9eae8fb8f097069
|
|
| BLAKE2b-256 |
cffad9648610d6f0de5378af7aecc6ccfd4f77fac957e6d7516b40138d83032c
|
File details
Details for the file listwash-0.1.0-py3-none-any.whl.
File metadata
- Download URL: listwash-0.1.0-py3-none-any.whl
- Upload date:
- Size: 65.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.14.5
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4829c5ffbbc63e34a351464cfef95fb743a16f6d4f299279e1c36125169a3a02
|
|
| MD5 |
db04c4db54c1df185542f5206d99c242
|
|
| BLAKE2b-256 |
4ea6604cff725f7bd0ff31a853ee0e4fd95c4d2ef70f4436130d1f06f0176ae0
|