Metadata-Version: 2.4
Name: pigeontrust
Version: 0.2.0
Summary: Honest email verification: syntax, DNS, SMTP mailbox probing and catch-all detection, with reasons instead of a bare boolean.
Project-URL: Homepage, https://github.com/rajkanani/Pigeontrust
Project-URL: Repository, https://github.com/rajkanani/Pigeontrust
Project-URL: Issues, https://github.com/rajkanani/Pigeontrust/issues
Project-URL: Changelog, https://github.com/rajkanani/Pigeontrust/blob/main/CHANGELOG.md
Author: Raj Kanani
License-Expression: MIT
License-File: LICENSE
Keywords: catch-all,deliverability,disposable-email,email,email-validation,email-verification,mx,smtp
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Communications :: Email
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.9
Requires-Dist: dnspython>=2.4
Provides-Extra: dev
Requires-Dist: mypy>=1.8; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest-cov>=4.1; extra == 'dev'
Requires-Dist: pytest>=7.4; extra == 'dev'
Requires-Dist: ruff>=0.5; extra == 'dev'
Description-Content-Type: text/markdown

# Pigeontrust

[![CI](https://github.com/rajkanani/Pigeontrust/actions/workflows/ci.yml/badge.svg)](https://github.com/rajkanani/Pigeontrust/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/pigeontrust.svg)](https://pypi.org/project/pigeontrust/)
[![Python versions](https://img.shields.io/pypi/pyversions/pigeontrust.svg)](https://pypi.org/project/pigeontrust/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)

Email verification that tells you **why**, and admits when it doesn't know.

Pigeontrust checks whether an email address can actually receive mail: syntax,
DNS, a real SMTP conversation with the mailbox provider, and catch-all
detection. No web server, no API key, no external service.

## The problem with `True` / `False`

Most email validators return a boolean. That collapses three completely
different situations into one answer:

| What actually happened | Boolean says | What you should do |
|---|---|---|
| The mail server said "no such mailbox" | `False` | Drop the address |
| The domain accepts every address, so nothing was proven | `False` | Keep it, send carefully |
| Your IP is on a blocklist, so the server refused to talk | `False` | **Keep it — this is about you, not them** |

That third row is not hypothetical. Here is a real reply Pigeontrust received
while checking `info@github.com` from a home connection:

```
550 5.7.1 Service unavailable, Client host [122.170.106.220] blocked using Spamhaus
```

A boolean validator reports `info@github.com` as invalid. It isn't. The
enhanced status code `5.7.1` means *policy*, and the message names the sender's
own IP. Pigeontrust returns `UNKNOWN` / `blocked_by_server` and says so.

## Install

```bash
pip install pigeontrust
```

Requires Python 3.9+. The only dependency is `dnspython`.

## Quick start

```python
from pigeontrust import verify

result = verify("someone@example.com")

print(result.verdict)     # deliverable
print(result.reason)      # mailbox_exists
print(result.confidence)  # 0.95
print(result.explain())   # The mail server accepted this recipient.
```

### The four verdicts

Every verification lands on exactly one of these. They are constants you
compare a result against — not something you call:

| `Verdict` | Means | What to do |
|---|---|---|
| `DELIVERABLE` | The mail server confirmed the mailbox exists | Send |
| `UNDELIVERABLE` | Confirmed bad: no such mailbox, no mail server, or bad syntax | Drop it |
| `RISKY` | May work, but catch-all, disposable, or a role account | Your call |
| `UNKNOWN` | We could not tell | Keep it, ask again later |

You use them by comparing against `result.verdict`:

```python
from pigeontrust import verify, Verdict

result = verify("someone@example.com")

if result.verdict is Verdict.UNDELIVERABLE:
    remove_from_list(result.email)
elif result.verdict is Verdict.UNKNOWN:
    retry_tomorrow(result.email)
```

`UNKNOWN` is not a soft `UNDELIVERABLE`. Treat it as "no data", never as a
reason to delete an address.

A verdict prints and compares as its lowercase name, so all of these work:

```python
print(result.verdict)                     # deliverable
print(f"{result.email}: {result.verdict}")# someone@example.com: deliverable
result.verdict == "deliverable"           # True
result.verdict is Verdict.DELIVERABLE     # True  <- prefer this in code
json.dumps({"verdict": result.verdict})   # {"verdict": "deliverable"}
```

Prefer `is Verdict.DELIVERABLE` over the string comparison: a typo in
`"delivrable"` silently evaluates to `False`, while a typo in
`Verdict.DELIVRABLE` raises `AttributeError` immediately.

There is also a `__bool__` for quick scripts — `if verify(email):` is true only
for `DELIVERABLE` — but read `.verdict` in anything that matters.

`Reason` works the same way, and there are far more of them. `result.explain()`
turns whichever one you got into a readable sentence:

```python
print(result.reason)      # mailbox_not_found
print(result.explain())   # The mail server said this mailbox does not exist.
```

## Configure it properly

The two settings that most affect accuracy are the identity you present to the
remote mail server. Servers treat an unresolvable `EHLO` name and a bouncing
`MAIL FROM` as spam signals, and answer accordingly.

```python
from pigeontrust import Pigeontrust

pigeon = Pigeontrust(
    helo_host="mail.mycompany.com",  # should resolve to the IP you connect from
    from_address="verify@mycompany.com",  # should be a real, deliverable address
    smtp_timeout=10,
    max_workers=8,
)

result = pigeon.verify("someone@example.com")
```

Keep the instance around. It caches MX records and catch-all findings per
domain, so a fresh one throws away everything it learned.

## Bulk verification

```python
results = pigeon.verify_many(addresses)  # returned in input order

for r in results:
    print(r.email, r.verdict.value, r.reason.value)
```

Addresses are grouped by domain. Each domain gets **one** MX lookup, **one**
catch-all probe, and **one** SMTP connection reused for every recipient on it.
Domains are checked concurrently; addresses within a domain are not — hammering
a single mail server with parallel connections is the fastest way to get your
IP blocked.

Override the concurrency for a single call when you need to:

```python
pigeon.verify_many(addresses, max_workers=16)   # 16 domains at a time
pigeon.verify_many(addresses, max_workers=1)    # fully sequential
```

## Two shortcuts

When you want one number to sort on, or a plain yes/no:

```python
pigeon.trustworthiness("someone@example.com")    # 0.0 - 1.0
pigeon.check_availability("someone@example.com") # True only if DELIVERABLE
```

`trustworthiness()` folds the verdict and its confidence into a single score: a
confirmed deliverable address lands near 1.0, a confirmed bad one near 0.0, and
anything `UNKNOWN` sits at 0.5 — because that is genuinely where it belongs.
Useful for ranking a list; not a substitute for reading `.verdict` when you are
deciding whether to delete something.

The caches live on the instance, so drop them when you want fresh answers:

```python
pigeon.clear_caches()   # forget all MX and catch-all results
```

## Async

Verification is almost entirely waiting on other people's servers, so it
belongs on an event loop. If you are calling this from FastAPI, Starlette,
aiohttp or a worker loop, use the async API rather than pushing blocking calls
into a thread pool:

```python
from pigeontrust import averify, averify_many, AsyncPigeontrust

result = await averify("someone@example.com")
results = await averify_many(addresses)
```

```python
pigeon = AsyncPigeontrust(
    helo_host="mail.mycompany.com",
    from_address="verify@mycompany.com",
    max_workers=16,          # domains in flight at once
)

result = await pigeon.averify("someone@example.com")
results = await pigeon.averify_many(addresses, max_workers=32)
score = await pigeon.atrustworthiness("someone@example.com")
ok = await pigeon.acheck_availability("someone@example.com")
```

The result object, verdicts and reasons are identical to the sync API — both
paths run through the same decision code, so they cannot disagree. There is a
test suite dedicated to proving that.

**What async actually buys you.** It is not faster for a handful of addresses;
the thread pool handles those fine. It wins on two things: it never blocks your
event loop, and it scales past what a thread pool comfortably holds. Measured
on 200 domains, DNS only:

| | 6 domains | 200 domains |
|---|---|---|
| `verify_many` | 5.5s | 3.5s |
| `averify_many` | 5.1s | **1.2s** |

Implemented directly on `asyncio` streams and dnspython's async resolver, so
the async support adds no dependencies.

## Disposable domains: which list?

By default pigeontrust checks a small curated list bundled with the package —
offline, instant, and deliberately conservative. For real coverage, opt into
the community list:

```python
Pigeontrust(disposable_source="remote")   # ~8,200 domains, downloaded once
Pigeontrust(disposable_source="both")     # union of both lists
Pigeontrust(disposable_source="blocklist.txt")            # your own file
Pigeontrust(disposable_source="https://example.com/list") # or your own URL
```

```bash
pigeontrust check someone@example.com --disposable-source remote
pigeontrust blocklist remote                    # how many domains, from where
pigeontrust blocklist remote --test 0-mail.com  # is one domain listed?
pigeontrust blocklist remote --refresh          # force a re-download
```

The remote list comes from
[disposable-email-domains](https://github.com/disposable-email-domains/disposable-email-domains),
which is released under CC0-1.0. It is **not vendored** into this package — it
is fetched on first use and cached for a week, so you get a current list rather
than whatever was true when pigeontrust was last released. If the download
fails and a cached copy exists, the cached copy is used; a stale blocklist
beats crashing a verification run.

The difference is substantial — 325 domains bundled versus 8,201 remote — but
it is opt-in on purpose. A bigger list means more false positives, and only you
know what one costs you.

```python
from pigeontrust import DisposableBlocklist

blocklist = DisposableBlocklist.from_url()   # or .bundled() / .from_file(path)
"mailinator.com" in blocklist                # True
len(blocklist)                               # 8201
```

## Catch-all detection

Some domains accept mail for every address. On those, a `250` proves nothing.
Pigeontrust asks the server about a random 24-character mailbox that cannot
exist:

```python
result = pigeon.verify("anyone@catchall-domain.com")

result.verdict  # Verdict.RISKY
result.reason  # Reason.CATCH_ALL
result.is_catch_all  # True
result.confidence  # 0.35
```

`is_catch_all` is `True`, `False`, or `None`. `None` means the probe was
inconclusive — don't coerce it to a bool.

## What else you get

```python
result.did_you_mean  # "gmail.com" for a typo'd "gmial.com"
result.is_disposable  # mailinator.com and friends
result.is_role_account  # info@, support@, admin@
result.is_free_provider  # gmail.com, yahoo.com, outlook.com
result.mx_hosts  # the domain's mail servers, in priority order
result.smtp_code  # 550
result.smtp_message  # "5.1.1 User unknown"
result.checks  # per-stage outcomes, with timings
result.to_dict()  # JSON-ready
```

## Command line

```bash
pigeontrust check someone@example.com
pigeontrust check someone@example.com --json
pigeontrust bulk addresses.txt --output results.csv
```

```
$ pigeontrust check info@github.com
[?] info@github.com
    verdict     unknown  (confidence 10%)
    reason      blocked_by_server
    why         The mail server refused the recipient for policy reasons.
    mail server github-com.mail.protection.outlook.com
    smtp reply  550 5.7.1 Service unavailable, Client host [...] blocked using Spamhaus
    flags       role account
    took        3322 ms
```

Exit codes for `check`: `0` deliverable, `1` undeliverable, `2` risky,
`3` unknown, `4` usage error.

## Two things you need to know before trusting the results

**Outbound port 25 is blocked on most networks.** AWS, GCP, Azure and nearly
every home ISP block it by default. Without it no SMTP probing is possible, so
anything that depends on it comes back `UNKNOWN` with reason `port_blocked`.
Bad syntax, a missing domain and a null MX are still settled definitively,
since those never needed port 25. Pigeontrust tells you which case you are in
instead of guessing. If you need SMTP verification in the cloud you must
request an exemption from your provider, or route through a host that has port
25 open.

**The big providers no longer give usable answers.** Gmail, Outlook, Yahoo and
iCloud do not reliably reveal whether a mailbox exists: some accept every
recipient at `RCPT TO` and reject later, others rate-limit or vary their reply
depending on the sending IP's reputation. Either way the answer says more about
you than about the address. Rather than produce confident nonsense, Pigeontrust
returns `UNKNOWN` / `provider_unverifiable` for them:

```python
verify("anything@gmail.com").reason  # Reason.PROVIDER_UNVERIFIABLE
```

Override it if you want the raw answer:

```python
Pigeontrust(unverifiable_domains=frozenset()).verify("anything@gmail.com")
```

Anyone claiming reliable Gmail mailbox verification is either sending real mail
or guessing.

## Turning stages off

```python
Pigeontrust(check_smtp=False)  # syntax + DNS only; fast, no port 25 needed
Pigeontrust(check_catch_all=False)  # skip the extra probe per domain
Pigeontrust(check_disposable=False)  # skip the blocklist
```

`check_smtp=False` is the right choice for signup-form validation: it catches
typos and dead domains without ever opening an SMTP connection, so it works
everywhere and finishes in the time of a single DNS lookup.

## Responsible use

SMTP probing opens real connections to other people's mail servers. Pigeontrust
never sends a message — it stops before `DATA` — but at volume this still looks
like address harvesting, and providers will rate-limit or blocklist you for it.

- Verify addresses you have a legitimate reason to contact.
- Leave `per_probe_delay` and `max_workers` at sane values.
- Do not use this to enumerate valid addresses on a domain you don't own.

## Contributing

See [CONTRIBUTING.md](CONTRIBUTING.md). New disposable domains and better SMTP
reply classification are especially welcome — if you hit a reply that gets
classified wrongly, please open an issue with the exact text.

## License

MIT — see [LICENSE](LICENSE).
