Mail Greylist

RFC 6647 email greylisting primitive. Defers first-seen senders with a 4yz SMTP tempfail so transient connections (spam, snowshoe campaigns, single-attempt botnets) drop and legitimate MTAs (which retry per RFC 5321 §4.5.4) re-connect after the operator-configured minimum delay and pass through.

## Triplet fingerprint (RFC 6647 §4.4)

Each greylist entry is keyed by the operator-recommended triple:

- **Connection IP** (CIDR-normalized — default /24 for IPv4, /64 for IPv6 so adjacent retry hosts from the same MTA cluster share a single entry). - **RFC 5321 MailFrom** (envelope-from; lowercased; bounces carry <> and key as the literal empty string). - **First RFC 5321 RcptTo** — RFC 6647 §4.4 notes legitimate MTAs don't reorder recipients on retry, so keying on the first RcptTo is sufficient.

The triplet is hashed via b.crypto.namespaceHash("mail.greylist", ip-cidr + "\\0" + mailfrom + "\\0" + first-rcpt) so the on-disk key is unlinkable to the PII triplet (privacy + GDPR Art. 5(1)(c) data minimization).

## Window + TTL (RFC 6647 §4.5)

- **minDelayMs** — minimum delay between first-seen and accept-on-retry (default 5 minutes; RFC 6647 §4.5 recommends "from one minute to 24 hours"). Retries inside the window get another 4yz tempfail; the existing fingerprint stays in place. - **whitelistTtlMs** — duration to remember a passed-greylist entry (default 36 days; RFC 6647 §4.5 recommends ≥1 week). On expiry the entry is gc'd and the next first-seen attempt is deferred again. - **maxEntries** — operator-configurable upper bound on the active-fingerprint store (default 1M). Bounds memory for the in-memory backend; the dbStore backend is bounded by DB row count.

## Backend abstraction

b.mail.greylist.create({ store: , ... }) accepts any { get(key) → entry|null, put(key, entry, ttlMs), delete(key), gc(olderThanMs) → count }-shaped backend. In-memory default ships for single-process MX deployments; the operator wires a sqlite-backed adapter or external DB for multi-process MX fleets (a retry landing on a different process needs to see the fingerprint planted by the first attempt).

## Verdict

instance.check({ ip, mailFrom, rcptTo }){ action, reason, firstSeenAt?, ttlExpiresAt? }:

- **"defer"** — first-seen or retry-too-soon. Operator returns SMTP 451 4.7.1 (RFC 6647 §4.5 + RFC 5321 §4.2.5). - **"accept"** — within the post-acceptance whitelist window; operator continues the SMTP transaction. - **"accept-first-pass"** — retry after minDelayMs elapsed on a previously-deferred fingerprint; operator continues AND the framework marks the fingerprint as whitelisted for the full TTL window.

## CVE / threat model

- **Snowshoe + single-attempt bot flood** — the defining defense: transient sources don't retry, so they never reach the message body. Pre-DKIM / pre-content-scan defense — cheap rejection. - **Fingerprint-store poisoning** — operator-supplied IPs + mailfrom strings are hashed (no raw PII on disk) and bounded (maxEntries); a hostile peer that tries to inflate the store hits the cap and the framework rotates oldest-first. - **CIDR-aggregation bypass** — operators with retry-aware MTA clusters (Gmail, Outlook, AWS SES) need /24 IPv4 and /64 IPv6 so the cluster's retry from a different host in the same subnet passes; the defaults match real-world MTA behavior.

## When NOT to greylist

- Listserv submissions (operator opts the listserv source out via allowedSources per RFC 6647 §6.2). - First-time newsletter sign-up confirmations (a single first attempt would defer the confirmation email; operator opts submission relay paths out). - High-priority transactional sources the operator has direct relationship with (banking, healthcare 2FA, etc.).

b.mail.greylist.create(opts?) #

stable0.9.34
{
  profile:        "strict" | "balanced" | "permissive",
  posture:        "hipaa" | "pci-dss" | "gdpr" | "soc2",
  store:          { get, put, delete, gc } — pluggable backend
  minDelayMs:     number — overrides profile minimum-delay window
  whitelistTtlMs: number — overrides profile post-acceptance TTL
  maxEntries:     number — in-memory backend's entry cap
  allowedSources: Array — IPs / CIDRs that skip greylisting
  audit:          b.audit namespace
}

Build a greylist instance. Returns an object with .check(ctx) → Promise and .gc({ olderThanMs }) → Promise<{ removed }>.

var gl = b.mail.greylist.create({ profile: "strict" });
var v  = await gl.check({
  ip:       "203.0.113.42",
  mailFrom: "sender@example.com",
  rcptTo:   "alice@operator.example",
});
if (v.action === "defer") return reply(451, "4.7.1 " + v.reason);

b.mail.greylist.compliancePosture(posture) #

stable0.9.34

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

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

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