Email pattern detection for verification means working out how a domain's mail server answers probes, before you trust any single reply. This guide shows the DNS lookup, the SMTP probe and the classifier, then sizes a real segment by status in LeadOcean.
Do not confuse this with the address pattern, such as first.last@acme.com. That one is covered in what is an email pattern. Here a pattern is a server behaviour.
Related: email verification explained, best email verification tools, the deliverability and verification guide and pricing. The hub for this section is the LeadOcean blog.
TL;DR
- A
250reply toRCPT TOis not proof of a mailbox. Check MX records, probe a real and a random address, and time both. - Six patterns cover almost every result: standard, catch-all, greylisting, tarpit, security gateway and no mail server.
- The walkthrough takes about 40 minutes and spends 0 records. Sizing by
emailStatusis free on every plan, Free included.
Prerequisites
- A LeadOcean API key from app.leadocean.io, exported as
$LEADOCEAN_API_KEY. The free plan needs no card. dig,ncand Python 3.- A machine that can open outbound port 25. Most cloud hosts and home networks block it. If yours does, steps 2 and 3 will not run there.
- Permission to probe the domains you test. Use your own domains or ones you have a reason to check.
Step 1: Read the MX records
The MX lookup settles two of the six patterns before you open a connection. No MX means no mail server. A gateway hostname in the MX list means a security gateway sits in front of the mailbox.
dig +short MX acme.comA domain with no usable MX can still publish a null MX. RFC 7505 defines 0 . as an explicit statement that the domain accepts no mail (checked September 2026). Treat it as invalid and stop.
Gateways show up in the hostnames. These suffixes are common ones.
| MX hostname ends in | Gateway |
|---|---|
pphosted.com | Proofpoint |
mimecast.com | Mimecast |
barracudanetworks.com | Barracuda |
iphmx.com | Cisco |
Gateways accept at SMTP and filter afterwards. A 250 from one tells you the gateway is happy, not that the mailbox exists. The LeadMagic guide names the same gateway family (checked September 2026).
Step 2: Probe a real address and a random one
Open an SMTP session to the lowest-preference MX and ask about two recipients. You stop before DATA, so no message is sent. The RFC 5321 reply codes are the signal: 2yz is accept, 4yz is temporary, 5yz is permanent.
nc mx1.acme.com 25
EHLO probe.example.net
MAIL FROM:<probe@example.net>
RCPT TO:<jane.doe@acme.com>
RCPT TO:<qzkxwvhtnbpmrd@acme.com>
QUITRead the two RCPT replies side by side. A real 250 and a random 550 is a standard server that tells the truth. A 250 for both is a catch-all.
A 450 or 451 on the first address is a deferral. RFC 5321 section 4.5.4.1 says a sender should retry a temporary failure, with a gap of at least 30 minutes between tries (checked September 2026). That is the greylisting pattern: the server refuses a first-time sender and accepts the retry.
Time the session as well. RFC 5321 section 4.5.3.2 allows a server five minutes to answer RCPT TO, so a server can hold you for a long time and stay within the rules. A tarpit uses that room to slow automated clients down.
Step 3: Classify with a script
Run the probe in code so the decision rules are written down. This script uses only the standard library plus dig. It checks MX, flags gateways, probes a real and a random address, and times both.
import random, smtplib, string, subprocess, sys, time
GATEWAYS = {"pphosted.com": "Proofpoint", "mimecast.com": "Mimecast",
"barracudanetworks.com": "Barracuda", "iphmx.com": "Cisco"}
SLOW = 10 # seconds. A starting point, not a standard.
def mx_hosts(domain):
out = subprocess.run(["dig", "+short", "MX", domain],
capture_output=True, text=True).stdout
rows = [line.split() for line in out.splitlines() if line.strip()]
rows = sorted((int(pref), host.rstrip(".").lower()) for pref, host in rows)
return [host for _, host in rows if host] # a null MX ("0 .") leaves nothing
def rcpt(host, address, timeout=30):
start = time.monotonic()
try:
s = smtplib.SMTP(host, 25, timeout=timeout)
try:
s.ehlo("probe.example.net")
s.mail("probe@example.net")
code, _ = s.rcpt(address)
finally:
s.close()
except (smtplib.SMTPException, OSError):
code = None
return code, round(time.monotonic() - start, 1)
def classify(address):
domain = address.split("@")[1]
hosts = mx_hosts(domain)
if not hosts:
return {"domain": domain, "pattern": "no_mail_server"}
gateway = next((name for h in hosts for sfx, name in GATEWAYS.items()
if h.endswith(sfx)), None)
fake = "".join(random.choices(string.ascii_lowercase, k=14)) + "@" + domain
real, t_real = rcpt(hosts[0], address)
junk, t_junk = rcpt(hosts[0], fake)
if real is None:
pattern = "unreachable"
elif 400 <= real < 500:
pattern = "greylisted_or_deferred"
elif max(t_real, t_junk) > SLOW:
pattern = "tarpit"
elif real == 250 and junk == 250:
pattern = "catch_all"
elif real == 250:
pattern = "standard_accepts"
else:
pattern = "standard_rejects"
return {"domain": domain, "mx": hosts[0], "gateway": gateway,
"rcpt_real": real, "rcpt_random": junk,
"seconds": max(t_real, t_junk), "pattern": pattern}
if __name__ == "__main__":
for addr in sys.argv[1:]:
print(classify(addr))Run it as python3 probe.py jane.doe@acme.com. Keep the volume low and the rate slow. Hitting one server with thousands of probes from one IP gets that IP blocked.
The gateway flag is separate from the pattern. A domain can be a catch-all behind Proofpoint, and you want to know both.
This is also where the do-it-yourself route stops paying. Port 25 access, IP reputation and retry queues are a project of their own. If you only need to know which rows to send to, read the status your data provider already holds, as in the next step.
Step 4: Size the same segment by LeadOcean status
LeadOcean stores a deliverability status on each person's best email. The field is email_status, the filter is emailStatus, and there are 13 values. None of them names a gateway, which is why step 1 stays useful.
A sizing call with count=true and limit=1 is free. Run it once per status for the same segment.
curl -X POST "https://api.leadocean.io/v1/people/search?count=true" \
-H "x-api-key: $LEADOCEAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"jobLevel":["VP"],"jobFunction":["Sales & Business Development"],"country":["CA"],"emailStatus":["verified"],"emailType":["work"],"limit":1}'Repeat with ["catch_all"], ["catch_all_valid"] and ["unknown","risky"]. Read meta.total each time. It is capped at 100,000.
Three of those statuses map straight onto patterns:
catch_all: the domain accepts every address, so the mailbox is unconfirmed.catch_all_valid: a catch-all domain where the mailbox was confirmed by the provider's identity check. That covers Microsoft 365 managed tenants and Google Workspace only.unknown: a check ran and was inconclusive. Deferrals and slow servers belong in this bucket.
The search index carries no per-address verification record. A search count of verified is an upper bound, and enrichment gives the final answer for one person.
Step 5: Route each pattern
Give every pattern one action, and write the actions down before the first send.
| Pattern | Signal | Action |
|---|---|---|
| Standard, accepts | 250 real, 550 random | Send. |
| Standard, rejects | 550 on the real address | Suppress. |
| Catch-all | 250 for both | Send a small monitored batch, or keep to catch_all_valid. |
| Greylisting | 450 or 451 | Retry after 30 minutes or more, then decide. |
| Tarpit | Replies slower than your threshold | Allow for the delay once, then treat as unknown. |
| Gateway | MX on a gateway suffix | Send from a warmed domain and watch late bounces. |
| No mail server | No MX or null MX | Suppress. |
LeadOcean does not refund or credit back bounced emails. Read email_status before you send, not after.
To pull only the rows you chose, export with the same filters. Bulk CSV export is not on the Free plan. It is included from Solo ($97 a month) up. The app's Exports page has the same filter builder and shows the record price first.
curl -X POST https://api.leadocean.io/v1/exports \
-H "x-api-key: $LEADOCEAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"vp-sales-ca-verified","filters":{"jobLevel":["VP"],"jobFunction":["Sales & Business Development"],"country":["CA"],"emailStatus":["verified","catch_all_valid"],"emailType":["work"]},"limit":25,"columns":["person_id","profile_full_name","current_company_domain","email_N_address","email_N_status"]}'The N columns come back as email_1_address and email_1_status. One export takes up to 50,000 rows, and an account takes 500,000 a day. This test costs 25 records.
What you get
First, a real MX lookup for the reserved domain example.com, run on 2026-10-21. The answer is a null MX, the no-mail-server pattern.
$ dig MX example.com
;; ANSWER SECTION:
example.com. 300 IN MX 0 .Second, the classifier output for a placeholder domain. This is a sample of the output shape with placeholder values, not a live run.
{'domain': 'acme.com', 'mx': 'mx1.acme-example.com', 'gateway': None,
'rcpt_real': 250, 'rcpt_random': 250, 'seconds': 0.6, 'pattern': 'catch_all'}Third, the free count_leads MCP tool, run on 2026-10-21. The segment is VPs in Sales & Business Development in Canada with a work email. Each row is a count by one emailStatus value. None of them is a mailable total.
| emailStatus filter | Total | Exact |
|---|---|---|
verified | 1,602 | yes |
catch_all (also matches catch_all_valid) | 2,993 | yes |
catch_all_valid | 0 | yes |
unknown or risky | 1,161 | yes |
In this segment the catch_all slice is nearly twice the verified slice, and none of it is a confirmed catch_all_valid mailbox. A plain export of both would put a large unconfirmed block in your sequence. That is the case for detecting patterns before you send.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing or wrong x-api-key header | Check $LEADOCEAN_API_KEY is exported and the header is lowercase x-api-key. |
| 402 | Out of records. On the free plan the 1,000 are a one-off allowance | Size with count=true before pulling rows. Move to Solo ($97 a month) or Agency ($497 a month). |
| 429 | Above your plan's rate limit per account (5 a second on Free, 200 on Agency) | Back off and honour the Retry-After header. |
Empty data on a filter that should match | A bad enum value, such as vp where the list wants VP | Look up the exact string with GET /v1/enums/{name} or the free list_enum_values MCP tool. |
meta.total stops at 100,000 | The count is capped, so totalIsExact is false | Treat it as a floor and narrow the filters. |
400 export_limit_rows or 429 export_limit_daily | Over 50,000 rows in one export, or 500,000 in a day | Split the export by country or seniority. The daily limit resets, so run the rest later. |
FAQ
Can I tell a catch-all from a standard server with one probe?
No. One probe returns one reply. You need a real address and a random one at the same domain, and you compare the two replies.
Is a greylisted address invalid?
No. A 450 or 451 is a temporary refusal. Retry after at least 30 minutes before you decide, and mark it unknown until then.
Does LeadOcean tell me which domains sit behind a gateway?
No. The 13 email_status values do not name a gateway. Check the MX hostnames from step 1, and read email_status for the mailbox itself.
Do I need to run SMTP probes myself?
Usually not. Probing needs port 25, a clean IP and slow, patient retries. For most teams it is cheaper to filter on emailStatus and read the status that comes with each record.
How many records does this cost?
Steps 1 to 4 spend none. Sizing is free. The optional 25-row export in step 5 spends 25 records and needs Solo or above.
Know which addresses are safe before you send
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →