1# CLAUDE.md — working notes for this repo
2
3Read this first. It tells you where the project is, what is already decided, and
4what the next piece of work actually is.
5
6---
7
8## What this is
9
10An Apify actor that verifies email addresses in bulk and gets paid per address
11(Pay-Per-Event, `email_verified`, $0.002). Node 22 + TypeScript + Apify SDK v3.
12
13It is a **verification primitive called inside other people's pipelines** — the
14customer is usually a machine. That single fact drives every design decision:
15output is flat, small, and schema-stable. `status` and `sub_status` are never
16null. All 11 keys of `checks` are always present, always boolean.
17
18The easy 80% (syntax, MX, disposable lists) is commodity. All the value is in
1980% → 95%: honest catch-all detection, honest free-provider handling, and
20correct SMTP interpretation. Spend effort there, not on the wrapper.
21
22---
23
24## >>> NEXT TASK: step 8 — PPE config and publish <<<
25
26Step 7 is finished. The relay is deployed at `https://relay.angelnumbercodex.com`
27(Contabo VPS, Coolify, `relay/Dockerfile`), egress IP `62.146.183.121`, and the
28platform run proves the whole thing works end to end.
29
30Measured on Apify 2026-08-18, run `BDaovlccruOEdIoKf`, build 0.1.3, through the
31relay, same 166 labelled addresses:
32
33```
34SMTP via relay https://relay.angelnumbercodex.com (egress IP 62.146.183.121)
35verified : 166 in 40.6s
36status : deliverable=15 undeliverable=64 risky=84 unknown=3
37sub_status : free_provider_unverifiable=40 invalid_syntax=30 disposable=20
38 mailbox_not_found=19 no_mx_record=15 valid_mailbox=15
39 role_based=14 catch_all=10 smtp_timeout=3
40UNKNOWN RATE : 1.8%
41catch-all cache : 25 domains
42```
43
44That distribution matches the labelled set exactly, row for row. Three platform
45runs of the same input tell the whole story:
46
47
48|---|---|---|---|
49| deliverable | 0 | 0 | **15** |
50| unknown | 101 | 61 | **3** |
51| unknown rate | 60.8% | 36.7% | **1.8%** |
52
53The middle column is the free-provider fix on its own: 40 addresses stopped
54being reported `unknown` because of a socket failure that never affected their
55verdict. The last column is the relay.
56
57### Done since
58
59Pay-per-event is live and verified on build 0.1.5, run `JFJqRp9nKbVDvmiuY`:
60
61```
62Build : 0.1.5
63SMTP via relay ********* (egress IP 62.146.183.121)
64charged events : 166
65UNKNOWN RATE : 1.8%
66```
67
68No `WARN Ignored attempt to charge` — the event name in Console matches
69`CHARGE_EVENT` in [src/main.ts](src/main.ts). Both default events Apify
70pre-fills were deleted: `apify-actor-start` because the spec forbids a per-run
71charge, and `apify-default-dataset-item` because it fires on every dataset write
72and would bill the same address twice.
73
74`RELAY_URL` / `RELAY_TOKEN` live as **secret environment variables on the Actor**
75(Console → Actor → **Source** tab, bottom — not Settings). Never in the run
76input: customers must not need a relay of their own or ever see the token.
77`main.ts` reads them from `env` as a fallback for exactly this.
78
79`apify push` refuses to overwrite an Actor edited in Console and asks for
80`--force`. That is safe here: `.actor/actor.json` describes only name, title,
81description, version, buildTag, input, dockerfile, readme, storages and the
82memory bounds. Environment variables, monetization, categories, SEO and the icon
83exist only on the platform, so a forced push cannot clobber them. Verify after
84forcing anyway — this is the build that goes public.
85
86### What is left before Publish on Store
87
88
89|---|---|
90| Icon | missing — 512×512 PNG |
91| Categories | unset — Lead generation, Developer tools, Business |
92| Custom SEO details | enable and fill |
93| **Maximum cost per run** | **still `1 USD` — at $0.002/address that cuts a run off at 500 addresses. Set "No maximum limit".** |
94
95Then the publishing checklist at the bottom of this file.
96
97### The relay is a hard dependency, and once published it is a public one
98
99The actor cannot confirm a mailbox without it, so a per-event product has picked
100up an always-on component the spec never budgeted for. `main.ts` calls `/health`
101before verifying anything and fails the run rather than quietly returning a page
102of `unknown`, so an outage costs a failed run instead of wrong answers.
103
104Publishing raises the stakes. Apify **auto-tests a published Actor every day
105with its default input**, and three consecutive failures mark it *under
106maintenance* on the Store page. Because a dead relay fails the run by design,
107a three-day relay outage now costs public reputation, not just one customer's
108run. Monitor `/up`.
109
110The default input is the `prefill` in
111[.actor/input_schema.json](.actor/input_schema.json), and it is doing two jobs.
112It is the daily health check — four addresses, ~1.2s, far inside the 5-minute
113limit — and it is the first thing a visitor sees on the Store page, so it
114deliberately returns four different verdicts:
115
116```
117sumit@angelnumbercodex.com deliverable/valid_mailbox 100
118no.such.person.99312@angelnumbercodex.com undeliverable/mailbox_not_found 5
119someone@gmail.com risky/free_provider_unverifiable 55
120test@mailinator.com risky/disposable 20
121```
122
123Keep it pointed at domains the owner controls. It ran against `info@apify.com`
124at first, which would have meant probing Apify's own mailbox every day forever —
125bad manners, and a good way to get the relay's IP flagged by the platform you
126sell on.
127
128---
129
130## Why the relay exists: the Apify run failed in the way that matters
131
132Measured on the platform 2026-08-18, run `3Qhj42VZ2CAE1pubp`, build 0.1.1,
133same 166 labelled addresses that score 98.8% locally:
134
135```
136verified : 166 in 17.3s
137status : deliverable=0 undeliverable=45 risky=20 unknown=101
138sub_status : smtp_timeout=101 invalid_syntax=30 disposable=20 no_mx_record=15
139UNKNOWN RATE : 60.8%
140catch-all hit rate : 0% (0 domains cached)
141```
142
143**`deliverable=0`.** Not one mailbox confirmed. The 65 non-unknown records are
144exactly the ones that never needed a socket — 30 syntax, 20 disposable, 15
145no-MX. Every single address that required an SMTP connection came back
146`unknown/smtp_timeout`, and the catch-all cache holds 0 domains, which means no
147probe ever reached the `ok` outcome even once.
148
149This is the signature of **outbound port 25 being blocked on the platform**, and
150it is not a tuning problem. It is the thing "Things that will bite you" warned
151about, arriving in full.
152
153### CONFIRMED: Apify drops outbound TCP on port 25
154
155Build 0.1.2, `apify call --input-file=apify-diag.json`:
156
157```
158aspmx.l.google.com 268ms connect ETIMEDOUT 172.253.122.26:25;
159 connect ENETUNREACH 2607:f8b0:4004:c07::1a:25 - Local (:::0)
160mx.tino.vn 8285ms connect timeout after 8000ms to mx.tino.vn:25
161```
162
163Read the IPv4 lines only — the `ENETUNREACH` on IPv6 just means the container has
164no v6 route, which is normal and irrelevant. What matters:
165
166- **IPv4 SYN to `172.253.122.26:25` got no answer.** Google's MX from a US/EU
167 datacenter answers in single-digit milliseconds when it answers at all.
168- **`mx.tino.vn:25` got a full 8000ms and still nothing.** Not slow, not
169 refused — silence.
170
171Two unrelated destinations, one of them given eight seconds, both silent on
172IPv4:25. A refused connection returns `ECONNREFUSED` immediately and a
173reputation block returns a `5xx` banner. Silence is a firewall DROP rule.
174**Outbound port 25 is not available on the Apify platform, and no amount of
175timeout tuning or retry logic changes that.**
176
177### The same run exposed a real bug that had nothing to do with the platform
178
179The Google host failed in 268ms while the caller had asked for 8000ms. Node 22
180enables `autoSelectFamily`, and its `autoSelectFamilyAttemptTimeout` defaults to
181**250ms** — so on any dual-stack MX the effective connect timeout was 250ms, not
182`smtpTimeoutMs`. Every large mail provider is dual-stack. On a network where
183port 25 *does* work, any server more than a quarter second away would have been
184reported `unknown/smtp_timeout` for no reason at all, and the input's timeout
185setting was quietly doing nothing.
186
187Fixed in [src/smtp.ts](src/smtp.ts) by passing the caller's budget as
188`autoSelectFamilyAttemptTimeout`. This does not change the Apify outcome — the
189packets are dropped either way — but it was corrupting the timeout contract
190everywhere else.
191
192### Earlier: the first diagnostic run found two different failures, not one
193
194`apify call --input-file=apify-diag.json` on build 0.1.1 returned:
195
196```
197mx.tino.vn 8012ms "connect timeout"
198aspmx.l.google.com 261ms "" notes: []
199gmail-smtp-in.l.google.com 261ms "" notes: []
200```
201
202`mx.tino.vn` is a clean timeout: packets dropped, nothing came back. The Google
203hosts failed in 261ms — far too fast for a timeout — and reported **nothing at
204all**, which was a defect in this code rather than a property of the platform.
205Node 22 connects with `autoSelectFamily`, so a host with both AAAA and A records
206is attempted over both families at once; when every attempt fails the result is
207an `AggregateError` whose own `message` is the empty string, and the real
208`ECONNREFUSED` / `ENETUNREACH` sits in `.errors`. Reporting `err.message`
209blanked the cause on precisely the hosts that needed diagnosing.
210
211Fixed by `describeError` in [src/smtp.ts](src/smtp.ts), covered in
212[test/contract.test.ts](test/contract.test.ts).
213
214**So the cause is still open.** A fast failure on dual-stack hosts alongside a
215slow drop on a single-stack host is not one story — it could be egress :25
216filtering, or it could be a broken IPv6 route where IPv4 was never usefully
217tried. Those need opposite fixes.
218
219### A locked decision has become impossible — this is the owner's call
220
221Section 11 of the spec locks "no dedicated SMTP proxy or IP rotation in v1 —
222runs on Apify's default IP". That decision assumed port 25 works at all. It does
223not, so v1 as specified cannot exist. The choice belongs to the owner, not to
224whoever picks this up next. In order of cost:
225
2260. **Ask Apify support to allow egress on 25 for this actor.** Cheapest thing to
227 try and it costs one message. Do it before architecting around the problem.
228 Expect no — providers block 25 to stop outbound spam, and this actor's
229 traffic pattern looks exactly like what that rule exists to stop, even though
230 it never sends a message.
2311. **Relay through a host that permits outbound 25.** A small VPS, with the
232 actor reaching it over a port that is not blocked. Contradicts the locked
233 decision and adds an always-on component to a per-event product, but it is
234 the only option that keeps Layer 4 and therefore the product's actual value.
2352. **Ship without Layer 4** — syntax, DNS and list checks only. This is the
236 commodity 80% the spec explicitly says carries none of the value. It also
237 breaks the business case, not just the feature list: $0.002/address for
238 checks that free libraries do offline is not a defensible price, and
239 `deliverable/valid_mailbox` could never be returned at all.
2403. **Host the actor somewhere else** and give up Apify Store as the channel.
241
242Whichever is chosen, the README and the status table change with it. Claiming
243`deliverable/valid_mailbox` while the SMTP layer cannot run would be the one
244dishonest thing this project has avoided so far — and the local gate proves the
245code is right, so there is nothing to fix in it and nothing to hide.
246
247---
248
249## The local gate (done, for reference)
250
251The local gate is done: 166 rows, 98.8%, every group at 100% except
252`gmx.com` / `web.de` refusing this machine's IP at the SMTP banner. Numbers
253measured from a dev machine are a measurement of that machine's IP, so they
254cannot be the number the actor ships on.
255
256```bash
257npm install -g apify-cli
258apify login # paste the token from Apify Console -> Settings -> API & Integrations
259apify push
260```
261
262Then run it on the platform with the labelled addresses as input. Build the
263input file with `fs.writeFileSync`, **not** by redirecting stdout with `>`:
264Windows PowerShell writes redirected output as UTF-16LE with a BOM, and the
265Apify CLI rejects it with `Unexpected token '' ... is not valid JSON`.
266
267```bash
268node -e "const fs=require('fs');const emails=fs.readFileSync('test/fixtures/labeled.csv','utf8').split('\n').map(l=>l.trim()).filter(l=>l&&!l.startsWith('#')).map(l=>l.split(',')[0]).filter(e=>e&&e.toLowerCase()!=='email');fs.writeFileSync('apify-input.json',JSON.stringify({emails,concurrency:15,smtpTimeoutMs:8000,heloName:'mail.angelnumbercodex.com',mailFrom:'verify@angelnumbercodex.com'},null,1),'utf8');console.log('wrote',emails.length,'emails')"
269apify call --input-file=apify-input.json
270```
271
272Extract the addresses from the CSV rather than typing them — one typo and the
273label no longer matches any record, which quietly turns into a "miss".
274
275`heloName` / `mailFrom` point at `angelnumbercodex.com` on purpose: its SPF,
276DKIM and DMARC are clean, so it is a better SMTP identity than the placeholder
277defaults.
278
279Export that run's dataset as JSON from the Console (Storage → Dataset →
280Export → JSON), then score it locally against the same labels:
281
282```bash
283npm run accuracy -- --dataset path/to/dataset.json
284```
285
286Nothing is verified locally in that mode — the exported records are scored
287against the labels and printed with the same per-group report, so the two runs
288are directly comparable.
289
290**What to look at, in order:**
291
2921. `unknown rate`. KPI #1. Under ~5% is good, over 15% means the platform IP is
293 being blocked and the product is not worth its price.
2942. `free-provider-ip-blocked`. If GMX and web.de pass on Apify, that group is
295 just a dev-machine artefact and the rows can move back into `free-provider`.
2963. `own-domain-valid` and `own-domain-invalid`. If these drop on Apify, that is
297 the money path failing on the real egress IP and it blocks publishing.
298
299Only after that: step 8 (publish + configure PPE in Apify Console).
300
301### Deliberate gap: `greylist` has no rows
302
303No public server was found that greylists reliably on demand, and a server that
304greylists only sometimes is a worse test than none — it would make the gate
305flap for reasons unrelated to the code. The `4xx -> unknown/greylisted` mapping
306is covered offline in [test/contract.test.ts](test/contract.test.ts) instead.
307Do not chase this by adding a flaky row.
308
309### If more rows are ever needed on the owned domain
310
311Forwarders are the cheap way in: Tino's panel (`mail.tino.vn` → **Chuyển tiếp**)
312creates them on the free plan, and `mx.tino.vn` answers `250` to `RCPT TO` for a
313forwarder exactly as it would for a real mailbox. Mail sent there is delivered,
314so `deliverable/valid_mailbox` is the honest verdict — not a way of faking a
315green row. Use personal-name local parts; a role local part diverts to
316`risky/role_based`.
317
318**Never switch on the catch-all toggle for `angelnumbercodex.com`.** Catch-all
319makes every address on a domain return `risky/catch_all` without the real
320address ever being probed, so enabling it would collapse `own-domain-invalid`
321(15), `own-domain-valid` (15), `own-domain-valid-control` (1) and
322`role-vs-hard-550` (2) into that one verdict — 33 rows destroyed. The catch-all
323group uses third-party domains instead, confirmed by raw SMTP independent of
324this code, with `catch-all-control` (figma.com) guarding that the group still
325means something.
326
327`info@` and `support@` are deliberately left non-existent: they are the
328`role-vs-hard-550` rows proving a role local part does not override a hard
329`550`. Creating them would delete that test.
330
331---
332
333## Current state
334
335
336|---|---|---|
337| 1 | Scaffold, input schema, output contract | done |
338| 2 | Layers 1–3 (syntax, MX+A, disposable/role/free/tag) | done |
339| 3 | Layer 4 SMTP + mandatory catch-all probe | done, swappable transport (direct / relay) |
340| 4 | Free-provider branch | done |
341| 5 | Deterministic scoring | done |
342| 6 | Cache + batch + concurrency | done, cross-run cache verified |
343| 7 | Accuracy gate — labelled set | done: 100% over 166 rows locally, and the Apify run through the relay matches the labels exactly |
344| 8 | PPE config + publish | PPE configured and verified on build 0.1.5; icon, categories, SEO and the max-cost limit are what remain |
345
346`npm test` — 14/14 pass, no network needed.
347
348Gate (2026-08-14, residential IP listed on Spamhaus):
349
350```
351100% (166/166) unknown rate 1.8%
352
353bad-syntax 100% (30)
354no-domain 100% (15)
355disposable 100% (20)
356free-provider 100% (32)
357free-provider-microsoft 100% (6)
358own-domain-invalid 100% (15)
359own-domain-valid 100% (15) <- the money path
360own-domain-valid-control 100% (1)
361catch-all 100% (10)
362catch-all-control 100% (1)
363role-based 100% (14)
364role-vs-hard-550 100% (2)
365a-record-fallback 100% (2)
366dns-servfail 100% (1)
367free-provider-ip-blocked 0% (2) <- gmx.com / web.de refuse this IP
368```
369
370Earlier runs on the smaller 144-row set scored 97.2% / 98.6% / 98.6%. The
371spread came from `proton.me` + `protonmail.com`, which share
372`mail.protonmail.ch` and get throttled about one run in three, taking both rows
373with a single probe.
374
375The spread is entirely environmental and was measured, not guessed:
376
377- `gmx.com` / `web.de` fail on **every** run —
378 `554 Nemesis ESMTP Service not available` at the banner. United Internet
379 refuses this IP outright.
380- `proton.me` / `protonmail.com` fail on roughly **one run in three**. They share
381 `mail.protonmail.ch`, so one throttled probe takes both rows with it.
382
383Neither is a logic defect. **Do not add retries or widen timeouts to chase
384them** — that is tuning the code against an IP the actor is about to stop using.
385Re-measure on Apify instead.
386
387### Ground truth already established — do not re-probe to rediscover it
388
389- `angelnumbercodex.com` — MX `mx.tino.vn`, hosted on Tino's free email plan.
390 **Not catch-all** (a random local part gets `550 5.1.1 No such user`).
391 Existing: `admin@` (mailbox), 15 personal-name forwarders (`sumit@`,
392 `linh.nguyen@`, `nam.tran@`, `hoa.pham@`, `duc.le@`, `minh.vu@`, `thu.ha@`,
393 `quang.do@`, `bao.ngoc@`, `tien.dat@`, `my.linh@`, `hoang.anh@`,
394 `phuong.thao@`, `khanh.duy@`, `ngoc.mai@`) and 12 role forwarders (`sales@`,
395 `contact@`, `no-reply@`, `noreply@`, `billing@`, `help@`, `office@`, `team@`,
396 `hello@`, `mail@`, `webmaster@`, `postmaster@`).
397 **`info@` and `support@` do not exist and must stay that way** — they are the
398 `role-vs-hard-550` rows.
399- Tino's panel blocks all admin menus until it sees SPF + MX + DKIM. The domain
400 once carried **two** `v=spf1` records (a leftover ImprovMX one) and **two**
401 DMARC records; per RFC 7208 §4.5 and RFC 7489 duplicates are a hard error, so
402 the check could never pass. Both duplicates were removed. If the panel locks
403 up again, audit for duplicate TXT records before assuming propagation delay.
404- `mivietnam.com` — A record, **no MX**, port 25 closed. Exercises
405 `a_record_fallback` + `unknown/smtp_timeout`. Company domain: 2 rows only,
406 never bulk-probe it.
407- The 8 `*.mivietnam@gmail.com` company addresses are real. They must verify
408 identically to the fake Gmail rows — that is the whole point of Branch A.
409- `gmx.com` / `web.de` reject this IP with
410 `554 Nemesis ESMTP Service not available`.
411- Confirmed catch-all by raw SMTP (two random local parts each, both `250`):
412 `stripe.com`, `shopify.com`, `atlassian.com`, `vercel.com`, `cloudflare.com`,
413 `digitalocean.com`, `airbnb.com`, `canva.com`. Confirmed **not** catch-all:
414 `figma.com` (`550`), same Google infrastructure — which is what makes it a
415 usable control.
416
417### Three real bugs the gate caught (all fixed)
418
4191. `outlook.co.uk` was in the free-provider list but is **not** Microsoft — its
420 MX is `uk.mx1.mailanyone.net`. Removed; every other entry in the list was
421 verified against its provider's own MX infrastructure.
4222. DNS `SERVFAIL` was being reported as `undeliverable/no_mx_record`. An
423 authoritative NXDOMAIN is a fact; a resolver failure is not. Now
424 `unknown/smtp_timeout`, and such results are never cached.
4253. The catch-all probe cache was keyed by domain, so `proton.me`,
426 `protonmail.com` and `pm.me` each opened their own connection to the shared
427 `mail.protonmail.ch` and got throttled. Free-provider probes are now keyed by
428 MX host, and concurrent misses share one in-flight promise.
429
430---
431
432## Layout
433
434```
435.actor/
436 actor.json actor metadata, memory 256–1024 MB
437 input_schema.json Apify Console input form
438 dataset_schema.json Store "Overview" table view
439 Dockerfile two-stage build on apify/actor-node:22
440 pay_per_event.json reference copy of the PPE pricing (Console is authoritative)
441src/
442 types.ts THE OUTPUT CONTRACT. Change nothing here without changing the README.
443 lists.ts role / free-provider / disposable tables
444 dns.ts Layer 2: MX with A fallback, RFC 7505 null MX
445 smtp.ts raw socket, reply parsing, error description. Never sends DATA.
446 probe.ts Layer 4 as a swappable transport: the catch-all probe, RCPT of an
447 address list, SMTP reply interpretation, and directLayer4
448 relay-client.ts Layer 4 over HTTPS, for hosts that cannot open port 25
449 relay-server.ts the other end: same probe.ts, run where port 25 works
450 verify.ts Layers 1+2+3, the decision tree, scoring. The heart of it.
451 util.ts concurrency pool, TTL cache, email extraction from txt/csv
452 main.ts Apify plumbing: input, caches, push batching, PPE charge, KPI log
453relay/
454 README.md why the relay exists, and how to deploy it
455test/
456 contract.test.ts schema-stability + code-mapping tests, offline
457 accuracy.ts the step-7 gate, makes real DNS/SMTP calls
458 fixtures/labeled.csv the labelled set
459```
460
461Commands:
462
463```bash
464npm test # offline contract tests
465npm start # build + run locally, reads storage/key_value_stores/default/INPUT.json
466npm run accuracy # the real-network accuracy gate
467```
468
469---
470
471## Decisions already locked — do not re-litigate
472
473- Email only. **No** phone, LinkedIn, or LLM enrichment. Ever, in v1.
474- **No** dedicated SMTP proxy or IP rotation in v1. Runs on Apify's default IP.
475 The Gmail limitation is *disclosed*, not "solved" with proxies.
476- Flat output. The nested `raw` block stays behind `verbose`, default off.
477- PPE, event `email_verified`, $0.002/address to start. No per-run charge.
478 Platform-cost-passthrough toggle stays OFF for v1.
479- Schema stability is a hard contract, not a nice-to-have. It is the main wedge
480 against incumbents whose every field is nullable.
481
482If you feel tempted to add something from that list "to make it better" — stop.
483
484---
485
486## Things that will bite you
487
488**Never build a JSON file with `>` on Windows.** PowerShell redirection writes
489UTF-16LE with a BOM, so the file looks fine in an editor and fails everywhere
490else — the Apify CLI reports `Unexpected token '' ... is not valid JSON` and
491points at the input schema, which sends you looking in the wrong place
492entirely. Use `fs.writeFileSync(path, data, 'utf8')`.
493
494**Port 25 must be open outbound.** If every result comes back
495`unknown/smtp_timeout` with connection errors, that is the cause. Check before
496debugging anything else.
497
498**Egress IP reputation decides the unknown rate.** Measured on the dev machine:
499Microsoft returned `550 blocked using Spamhaus`, IBM/Oracle/Salesforce returned
500`554 Blocked - see ipcheck.proofpoint.com`. That is the IP, not the code.
501Re-measure on Apify before publishing — a high unknown rate is KPI #1 and the
502main churn driver. Every run logs `UNKNOWN RATE` and warns above 15%.
503
504**A 5xx about *them* beats a 5xx about *us*.** `RECIPIENT_REJECT` in
505[src/probe.ts](src/probe.ts) runs before `POLICY_REJECT` and wins. Exchange
506Online answers a missing mailbox with `550 5.4.1 Recipient address rejected:
507Access denied` — wording that `POLICY_REJECT` matched on "access denied", so every
508dead address on Microsoft 365 came back `unknown` instead of `undeliverable`.
509Proven live, not reasoned: from one IP in one session, a random local part gets
510that 550 while a real one gets `250 2.1.5 Recipient OK`. An IP block cannot do
511that. The labelled set never caught it because its Microsoft rows are all
512consumer domains, which take Branch A and never reach RCPT — there is no M365
513business domain in it, and adding one needs a clean egress IP, so the guard is
514an offline test plus the microsoft.com row in `npm run relay:health`.
515
516**A 5xx about *us* is not a verdict about *them*.** `POLICY_REJECT` in
517[src/probe.ts](src/probe.ts) demotes reputation/policy/blocklist rejections to
518`unknown/smtp_timeout` instead of `undeliverable/mailbox_not_found`. This is
519deliberate and is the biggest guard against false negatives. If you widen that
520regex, you risk turning real bounces into `unknown`; if you narrow it, you risk
521telling a customer a good address is dead. Change it only with test evidence.
522
523`isSoftDnsError` in [src/dns.ts](src/dns.ts) is the same rule one layer down.
524`ENOTFOUND`/`ENODATA` are answers; `ESERVFAIL` and friends are failures to ask.
525Only the former may produce `undeliverable`.
526
527**The cache lives in a *named* key-value store** (`email-verifier-cache`), not
528the default one. The default store is per-run and purged on start, so it cannot
529hold a 24h TTL across runs. Verified: run 1 cold 4.5s, run 2 warm 2.4s.
530
531**`heloName` / `mailFrom` are accuracy levers, not decoration.** Many MX servers
532downgrade or reject a session whose HELO name has no DNS. Point them at a domain
533you own. These two inputs are an addition to the original spec for exactly this
534reason.
535
536**Never send DATA.** The SMTP client reads reply codes only. If a change ever
537makes it send a message body, that is a defect, not a feature.
538
539---
540
541## The decision tree, condensed
542
543Layers run in order, stopping at the first terminal verdict.
544
5451. **Syntax** (validator.js) — invalid → `undeliverable/invalid_syntax`, stop.
5462. **DNS** — no MX and no A (or null MX) → `undeliverable/no_mx_record`, stop.
547 No MX but an A record → implicit MX, `a_record_fallback = true`.
5483. **Lists** — disposable → `risky/disposable`, stop (no SMTP wasted on it).
549 Role and free-provider flags are set here and used below.
5504. **SMTP** —
551 - *Free provider*: connect only. RCPT results from Gmail/Outlook/Yahoo are
552 meaningless, so no mailbox verdict is claimed →
553 `risky/free_provider_unverifiable`, `mailbox_exists = false`.
554 **This is correct behaviour, not a gap to hide.**
555 - *Everything else*: mandatory catch-all probe first
556 (`RCPT TO zzq-nonexist-<rand>@domain`). 250 → `risky/catch_all` for every
557 address on the domain, real address never probed. 550 → probe the real
558 address: 250 → `deliverable/valid_mailbox` (or `risky/role_based` if it is
559 a shared inbox), 550 → `undeliverable/mailbox_not_found`, 452/552 →
560 `risky/full_mailbox`, 4xx → `unknown/greylisted`, dead socket →
561 `unknown/smtp_timeout`.
562
563Scoring is a pure function of the verdict — no randomness, no model. Table lives
564in `SCORES` in [src/verify.ts](src/verify.ts) and is mirrored in the README.
565
566---
567
568## Publishing checklist (step 8)
569
5701. Accuracy gate passes.
5712. `apify push`.
5723. Apify Console → Actor → Publication → Monetization: Pay Per Event, event
573 `email_verified`, $0.002. Mirror any change back into
574 `.actor/pay_per_event.json` so it stays reviewable in git.
5754. Leave the "pay per event + usage" passthrough toggle OFF.
5765. Test memory setting: start at 256 MB, measure, adjust. This is I/O-bound and
577 memory directly sets the Compute Unit cost, which comes out of the payout.
5786. The README **is** the Store landing page and the SEO surface. Keep the full
579 status table in it — transparency is the differentiator, competitors hide it.