Mail RBL

RFC 5782 DNS-based blocklist (DNSBL) + allowlist (DNSWL) query primitive. Composes b.network.dns.resolver for the underlying DNS queries and surfaces a structured { listed, allowed, neutral, errors } shape for the MX listener (v0.9.34) and submission listener (v0.9.35) to consume per-connection.

## Query construction

- **IPv4** — octets reversed, blocklist domain suffixed. RFC 5782 §2.1: address 192.0.2.99 against bl.spamcop.net becomes the query name 99.2.0.192.bl.spamcop.net. - **IPv6** — nibble-reversed across all 128 bits (32 hex nibbles), blocklist domain suffixed. RFC 5782 §2.4: address 2001:db8::1 against ugly.example.com becomes 1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ugly.example.com. - **Domain blocklists** (Spamhaus DBL / SURBL — RFC 5782 §3) — query the domain directly against the list zone, no reverse.

## A-record semantics (RFC 5782 §2.1)

The A-record return is a SEMANTIC code, not a routable address. Convention is 127.0.0.x: - 127.0.0.2 — listed (generic). - 127.0.0.4+ — operator-specific sub-list (Spamhaus uses 127.0.0.4 SBL, 127.0.0.5 XBL, etc.). - 127.255.255.252+ — RFC 5782 §5 test addresses. The primitive exposes the raw bytes so operator's MX policy can inspect the sub-list code.

## TXT-record reason (RFC 5782 §2.2)

Many DNSBLs publish a TXT record alongside the A — short prose describing why the IP is listed (often with a URL for delisting). The primitive fetches it lazily — operator opts in via { withReason: true } per-query when they want to render the reason back to the peer via SMTP 550 message.

## DNSWL allowlists

b.mail.rbl.create({ ..., allowlists: [...] }) — operator wires any list as DNSBL (refuse on listed) OR DNSWL (allow on listed). Same query shape; the verdict semantics differ. RFC 5782 §3.2 notes TXT records on DNSWLs are operationally less useful since SMTP can't advise the peer WHY they were accepted, but the field is surfaced for audit visibility regardless.

## CVE / threat model

- **Blocklist-cache amplification** — each list query goes through b.network.dns.resolver so cache + TTL + serve-stale already defend against amplification + flood from a single hostile peer. - **DoS-by-query** — operator-configurable per-connection concurrent-query cap (default 8) and per-IP query timeout (default 5s); a slow / unresponsive list can't stall the MX listener. - **DNS-poisoning** — every response parses through b.safeDns (bounded RR counts, bounded TXT length) via the resolver, so a poisoned upstream response can't smuggle oversized rdata.

## Why it exists

The MX listener (v0.9.34) needs RBL queries on every accepted connection for SPF / IP-reputation evaluation; the submission listener checks operator's own submission-rate / spam-source lists. Without this primitive each consumer rolls its own reverse-IP construction + A-record sub-code interpretation, and the per-list query timeout / cap is operator-specific instead of framework-shared.

b.mail.rbl.create(opts) #

stable0.9.33
{
  resolver:    b.network.dns.resolver.create() instance, required
  blocklists:  Array — DNS zones (e.g. "bl.spamcop.net")
  allowlists:  Array — DNSWL zones (e.g. "list.dnswl.org")
  profile:     "strict" | "balanced" | "permissive"
  posture:     "hipaa" | "pci-dss" | "gdpr" | "soc2"
  withReason:  boolean — default false; fetch TXT record per A hit
  audit:       b.audit namespace
}

Build an RBL query instance. Returns an object with .query(ip, opts) → Promise and .queryDomain(domain, opts) → Promise methods.

var rbl = b.mail.rbl.create({
  resolver:   b.network.dns.resolver.create(),
  blocklists: ["zen.spamhaus.org", "bl.spamcop.net"],
});
var verdict = await rbl.query("192.0.2.99", { withReason: true });
if (verdict.listed.length) refuseConnection(verdict.listed[0].reason);

b.mail.rbl.reverseIp(ip) #

stable0.9.33

Build the reverse-DNS query name for an IPv4 or IPv6 address per RFC 5782 §2.1 / §2.4. Pure-functional helper exposed for operator tests and the b.mail.dnsbl extension primitive.

b.mail.rbl.reverseIp("192.0.2.99");   // → "99.2.0.192"
b.mail.rbl.reverseIp("2001:db8::1");  // → "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2"

b.mail.rbl.compliancePosture(posture) #

stable0.9.33

Return the effective profile name for a compliance posture, or null for unknown posture names.

b.mail.rbl.compliancePosture("hipaa");   // → "strict"

Last updated 2026-08-08T16:39:49.652Z by seeder.