Metadata-Version: 2.5
Name: listwash
Version: 0.1.0
Summary: Self-hosted email list verifier: syntax, MX, disposable detection, and polite SMTP mailbox probing with catch-all detection.
Project-URL: Homepage, https://github.com/ConStrut/listwash
Project-URL: Issues, https://github.com/ConStrut/listwash/issues
Author-email: ConStrut <12356888+ConStrut@users.noreply.github.com>
License: MIT
License-File: LICENSE
Keywords: bounce,catch-all,deliverability,email,email-verification,list-cleaning,mx,smtp,verification
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Information Technology
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Communications :: Email
Classifier: Topic :: Utilities
Requires-Python: >=3.9
Requires-Dist: dnspython>=2.0
Provides-Extra: dev
Requires-Dist: pytest>=7; extra == 'dev'
Requires-Dist: ruff>=0.4; extra == 'dev'
Description-Content-Type: text/markdown

# 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](https://github.com/rthalley/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 TO` and resets; it never
  sends `DATA`, 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 into `invalid` so 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:

1. **Syntax** — malformed addresses are `invalid` without touching the network.
2. **MX/DNS** — the domain must resolve and have mail servers (falls back to
   the RFC 5321 implicit-MX A record). Dead domains are `invalid`.
3. **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.
4. **SMTP probe** — connect to the domain's best MX on port 25 and issue
   `RCPT TO` for each address, without ever sending `DATA`.

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.1` for a bad address)
  but may greylist or rate-limit under volume → `unknown`.
- **Microsoft 365 / Outlook** returns `550 5.4.1 Access denied` for *both*
  unknown recipients *and* blocked probes — genuinely ambiguous, so listwash
  returns `unknown` rather than wrongly prune a real address.
- Servers that reject the probing IP by policy (`5.7.*`, "access denied",
  "blocked") → `unknown`, never `invalid`.
- Greylisting (`450`/`451`) → `unknown`; re-run later — `unknown` verdicts
  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-smtp` still 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

```python
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](https://github.com/disposable-email-domains/disposable-email-domains)
list.

## License

MIT
