Skip to content

Commit 13de5c3

Browse files
committed
The domain reputation check gets its own card: the bounce work is done, and asking whether we are blocklisted was never a bounce mechanism
1 parent 1e83bdb commit 13de5c3

2 files changed

Lines changed: 126 additions & 13 deletions

File tree

roadmap/p1-e2-email-bounce-detection-suppression.md

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,17 +26,17 @@
2626
> halt itself — none of which the epic decides. It also keeps the measured
2727
> evidence, which is not reproducible.
2828
29-
- **Status:** In Progress **the suppression half is built.** A bounce naming a dead
30-
address now ends the pursuit: the address joins the suppression list, its open deals
31-
reach `UNDELIVERABLE`, and a refusal at the SMTP door does the same thing through
32-
the same function. The policy is in *What the policy turned out to be* below, and
33-
every open question in this card is answered there except one. **What is left is
34-
the second half of one criterion — the operator cannot ask the system whether the
35-
domain is listed on a DNSBL.** That is the whole remainder.
36-
- **Priority:** Medium — was Critical while addresses were mailed forever; the failure
37-
mode that earned that rating is closed.
38-
- **Effort:** Small — DNSBL lookups against the sending domain, surfaced somewhere an
39-
operator reads.
29+
- **Status:** Done **a bounce naming a dead address now ends the pursuit.** The address
30+
joins the suppression list, its open deals reach `UNDELIVERABLE`, and a refusal at the
31+
SMTP door does the same thing through the same function. The policy is in *What the
32+
policy turned out to be* below, and every open question this card raised is answered
33+
there. **The one criterion it could not close — "is my domain listed?" — moved to
34+
[`p1-e2-sending-domain-reputation-check`](p1-e2-sending-domain-reputation-check.md)**,
35+
which is a reputation read rather than a bounce mechanism and had no business staying
36+
here.
37+
- **Priority:** Medium — was Critical while dead addresses were mailed forever; the
38+
failure mode that earned that rating is closed.
39+
- **Effort:** Small
4040
- **Area:** Pipeline
4141

4242
> **This card states a problem, not a solution.** It is written for someone
@@ -249,8 +249,11 @@ Stated as outcomes; how they are achieved is open.
249249
intervening. *(Already true — `warmth.py` halves capacity above tolerance; a
250250
suppressing bounce still records its `DeliveryEvent`, so this keeps working
251251
for the statuses that now also end the pursuit.)*
252-
- [ ] An operator can answer "what is my bounce rate?" and "is my domain listed?"
253-
from the system, not from a third-party website. *(The rate is arithmetic the
252+
- [] An operator can answer "what is my bounce rate?" and "is my domain listed?"
253+
from the system, not from a third-party website. **Moved to
254+
[`p1-e2-sending-domain-reputation-check`](p1-e2-sending-domain-reputation-check.md)**
255+
— it is a reputation read, not a bounce mechanism.
256+
*(Superseded text: the rate is arithmetic the
254257
log can already answer; **DNSBL listing is not built**, and it is the honest
255258
remainder of this card.)*
256259
- [x] A replacement address found later for the same person is still sendable.
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# The Operator Cannot Ask Whether Their Own Domain Is Blocked
2+
3+
> ## 📥 The last open criterion of [`p1-e2-email-bounce-detection-suppression`](p1-e2-email-bounce-detection-suppression.md), split out because the rest of that card closed.
4+
>
5+
> That card asked for two answers: *"what is my bounce rate?"* and *"is my domain listed?"*. The first
6+
> is arithmetic the mail log can already do. The second is not built at all, and this card is it.
7+
8+
- **Status:** To Do
9+
- **Priority:** High — the failure it prevents has already happened twice, and the second time was
10+
caught by hand.
11+
- **Effort:** Small
12+
- **Area:** OpenOutSend — a reputation read over the sending identity, and somewhere to show it.
13+
14+
## The incident that produced this card
15+
16+
**2026-08-27.** Preparing to resume sending, the operator's configured mailbox was
17+
`eracle@indieoutreach.app`. Before enabling it, an assistant ran the query by hand:
18+
19+
```
20+
multi.surbl.org indieoutreach.app → 127.0.0.64 listed (abuse)
21+
dbl.spamhaus.org indieoutreach.app → 127.0.1.2 listed (spam domain)
22+
zen.spamhaus.org not listed
23+
```
24+
25+
**Still listed, three weeks after the 2026-08-06 incident.** Authentication was clean the whole time —
26+
SPF, DKIM, DMARC, Google MX — so nothing the operator could see said anything was wrong. The plan that
27+
day was to resume cold outreach from that domain and would have gone ahead unquestioned.
28+
29+
**Nothing in the product would have said a word.** `warmth.py` measures the box's own bounce rate and
30+
halves capacity above tolerance, which is a *lagging* signal: it reacts once mail is already failing.
31+
A blocklist entry is the thing that makes mail fail, and it is public, free to query, and invisible to
32+
this system.
33+
34+
The same session found the other half of why that domain got listed: the old install recorded **zero
35+
bounces across 729 sends** and misfiled three non-delivery reports as human replies, so its true bounce
36+
rate was never measurable by anyone. Suppression now fixes the cause. This card fixes the *noticing*.
37+
38+
## User Story
39+
40+
As an operator about to send cold email, I want the tool to tell me whether my sending domain is on a
41+
public blocklist — before it sends anything — so that I find out from my own software rather than from
42+
a silent collapse in reply rate, and so that I never resume sending from a domain that is already
43+
burned.
44+
45+
**And once it is listed, I want to know it is still listed**, so that "we fixed the bounces" and "we
46+
are delisted" stay separate facts and I can tell when the recovery has actually worked.
47+
48+
## What to build
49+
50+
### 1. The check
51+
52+
A DNS query per list, over the **domain** of the mailbox's `from_address`. Listed means an `A` record
53+
exists at `<domain>.<zone>`; unlisted means `NXDOMAIN`.
54+
55+
| list | zone | what a hit means |
56+
|---|---|---|
57+
| Spamhaus DBL | `dbl.spamhaus.org` | the domain itself is classed as spam-associated |
58+
| SURBL | `multi.surbl.org` | the domain appears in spam bodies; `127.0.0.64` is the abuse list |
59+
60+
**IP blocklists are deliberately out of scope.** Mail leaves through Gmail's or a relay's addresses,
61+
so the sending IP's reputation is Google's and not the operator's — checking `zen.spamhaus.org` would
62+
report on somebody else's asset and teach the operator nothing they can act on. Domain reputation is
63+
the part they own, and the part they can lose.
64+
65+
### 2. Two traps that will otherwise be read as answers
66+
67+
- **A public resolver poisons the result.** Spamhaus refuses queries arriving from large open
68+
resolvers (`8.8.8.8`, `1.1.1.1`) and answers `127.0.1.255`*query blocked*, not *listed*. Read
69+
naively that is a false positive on every domain. The return code has to be interpreted, not merely
70+
tested for existence, and a blocked query must report **unknown**, never listed and never clean.
71+
A VM whose `/etc/resolv.conf` points at a cloud metadata resolver may hit exactly this.
72+
- **A consumer mailbox has no domain reputation to check.** Querying `gmail.com` will always come back
73+
clean and means nothing — the operator shares that reputation with everyone. For a consumer address
74+
the honest answer is *"you have no domain of your own; a suspension here takes the mailbox with it"*,
75+
not a green tick.
76+
77+
### 3. Where it surfaces
78+
79+
**`outsend status`**, a third verb beside `init` and `send`, mirroring the finder's. It answers without
80+
sending: which mailbox, its measured capacity and headroom, its bounce rate from the mail log, and the
81+
reputation of its domain. The bounce-rate half needs no new data — `report.bounce_rate` already exists
82+
and nothing shows it to anybody.
83+
84+
**And once per send pass**, cheaply, so a listing that appears mid-campaign is noticed within a pass
85+
rather than at the next time somebody thinks to look.
86+
87+
### 4. What it must not do
88+
89+
**It must not stop sending on its own.** A DNS timeout, a rate-limited resolver, or a mirror having a
90+
bad day would silently halt a campaign, and the operator would have no idea why. Say it loudly, in the
91+
pass's narration and in `status`; leave the decision with the person. This is the same rule
92+
`warmth.py` follows — it *reduces* capacity, it does not refuse to run.
93+
94+
## Done when
95+
96+
- [ ] `outsend status` reports, for each configured mailbox: bounce rate, remaining headroom, and
97+
whether its domain is listed on DBL and SURBL.
98+
- [ ] A blocked or failed query reports **unknown** and is visibly different from **clean**.
99+
- [ ] A consumer mailbox (`gmail.com` and friends) is described as having no domain of its own rather
100+
than being reported clean.
101+
- [ ] A send pass surfaces a listing without refusing to send.
102+
- [ ] No test reaches the network — the resolver is mocked at the boundary, with the real return codes
103+
(`127.0.1.2`, `127.0.0.64`, `127.0.1.255`, `NXDOMAIN`) as the cases.
104+
105+
## Not this card
106+
107+
- Requesting delisting. That is a form on someone else's website and a human decision.
108+
- Warming a new domain, or choosing one. Separate, and mostly not software.
109+
- Postmaster Tools / seed-list placement testing — richer signals, both needing volume this project
110+
does not have yet.

0 commit comments

Comments
 (0)