Skip to content

Why disposable email blocklists go stale, and how to keep yours fresh

· Updated · 7 min read · Disposable email

Disposable email blocklists go stale because the services they track keep registering new domains, and any copy you ship is frozen at the moment you copied it. The main open list, disposable-email-domains on GitHub, went from about 4,700 entries in October 2025 to about 9,900 in October 2026. A snapshot taken a year ago is missing over half of today's list. Keeping yours fresh means fetching on a schedule, sanity-checking each update, and reviewing new entries against your own users before you enforce them.

Staleness also runs the other way. Lists pick up wrong entries, and occasionally hostile ones. A good update process handles both.

How fast the open list moves

Here is the line count of disposable_email_blocklist.conf in the disposable-email-domains repository, taken from its git history:

Date Domains (approx.)
October 2023 3,600
October 2024 3,800
October 2025 4,700
April 2026 5,400
July 2026 7,900
October 2026 9,900

Growth was slow for years, then steep. The inflection lines up with a change in how the list is maintained: on 31 October 2025 the repo added fetch_domains.py, a script that collects the domains disposable services themselves expose (it currently has fetchers for 11 services, including Yopmail and Mail.tm). A GitHub Actions workflow now runs it daily at 00:00 UTC and opens a pull request with whatever is new, which the maintainers review and merge. The file was touched by 377 commits in the twelve months to 12 October 2026.

So the list is not just growing; it is growing on a daily cadence. Anything that copies it less often is behind by design.

Where stale copies come from

Most stale lists aren't neglected on purpose. They're built in.

Vendored files. Someone downloads the list into the repo when the signup form is built. It works, nobody touches it, and two years later it holds a third of the current entries.

Packages that bundle a snapshot. A package is only as fresh as its last release, and then only if you upgrade. The npm package named disposable-email-domains has not published a version since September 2022. The PyPI package of the same name, by contrast, released five versions in the week before this article, but your lock file still pins whichever one you installed.

Fallback copies. Even services that sync at runtime usually ship a bundled copy for cold starts. pingutil is no exception: the snapshot compiled into our Worker as a fallback is only refreshed when someone runs the sync script and redeploys. Before we last did that, it held about 6,900 entries while the live list we sync weekly from upstream had about 9,900. That gap is exactly why the fallback should only ever be a fallback.

Caches with no expiry. A list loaded once at process start, in a process that never restarts, is a vendored file with extra steps.

Lists also collect bad entries

Freshness isn't only about adding. Three things in the same repo's recent history are worth knowing about.

Typo domains. gmal.com, a common misspelling of Gmail, sat on the list from before 2018 until a maintainer removed it in December 2025. A typo domain on a blocklist turns a user's honest mistake into a confusing rejection instead of a "did you mean gmail.com?" prompt.

Poisoning attempts. In January 2026 a contributor reported, in issue #845, that one of the automated sources was a domain registered two days before a brand-new GitHub account proposed it as a fetcher, and that its feed mixed real disposable domains with domains belonging to established companies. The concern was straightforward: get a real company's domain onto a list that sites use to gate signups, and you can break that company's access across many services at once. The maintainers removed the fetcher and pulled all 33 domains that had come in through it. Automation made the list fresher, and also gave an attacker a shorter path into it.

Dead domains. On 12 October 2026 we resolved a sample of 395 entries from the list over DNS-over-HTTPS. 84 of them, about a fifth, returned NXDOMAIN. That's harmless: a domain that doesn't exist can't receive your verification email, so you'd reject it anyway. Don't bother pruning dead entries. Some get re-registered, and the cost of keeping them is negligible.

A safer way to update

The goal is an update loop that is frequent, cheap and hard to poison. Five rules:

  1. Fetch at runtime or from a scheduled job, not at build time. The raw GitHub URL returns an ETag, so a daily conditional request costs almost nothing when the file hasn't changed.
  2. Refuse updates that look broken. An empty body, an HTML error page or a list that suddenly lost a large share of its entries should be rejected, and the previous list kept.
  3. Check additions against your own users. If a newly added domain belongs to people who already sign in to your product, don't start blocking it automatically. Put it in front of a human.
  4. Keep local overlays. An allowlist for upstream entries you disagree with, and a denylist for domains you've seen abused that upstream doesn't have yet.
  5. Record the version you're running. Store the ETag or commit SHA with each applied update, so when a user reports a false rejection you can tell which update introduced it.

Rule 3 is the one most implementations skip, and it is the only one that addresses the poisoning scenario directly. If a targeted company's employees use your product, their domain is already in your user table. A genuine disposable domain rarely has long-lived active users.

Here is that logic as a small, framework-free function:

// list-update.mjs
const MAX_SHRINK = 0.05; // never drop more than 5% of entries in one update
const MAX_GROWTH = 0.5;  // more than +50% in one update needs a human

export function planUpdate(current, incoming, { activeUsersByDomain, allowlist = new Set() }) {
  const added = [...incoming].filter((d) => !current.has(d));
  const removed = [...current].filter((d) => !incoming.has(d));

  const problems = [];
  if (incoming.size < current.size * (1 - MAX_SHRINK)) {
    problems.push(`list shrank from ${current.size} to ${incoming.size}`);
  }
  if (current.size > 0 && incoming.size > current.size * (1 + MAX_GROWTH)) {
    problems.push(`list grew from ${current.size} to ${incoming.size}`);
  }

  // The poisoning check: would any new entry lock out people who already use you?
  const collateral = added
    .filter((d) => !allowlist.has(d))
    .map((d) => [d, activeUsersByDomain.get(d) ?? 0])
    .filter(([, n]) => n > 0)
    .sort((a, b) => b[1] - a[1]);

  return {
    apply: problems.length === 0 && collateral.length === 0,
    added: added.length,
    removed: removed.length,
    problems,
    collateral, // [['somecorp.com', 42], ...] -> review before blocking
  };
}

Feed it the current set, the freshly downloaded set, and a map of domain to active-user count. In Postgres, a rough version of that map is one query:

SELECT lower(split_part(email, '@', 2)) AS domain, count(*) AS n
FROM users
WHERE last_seen_at > now() - interval '90 days'
GROUP BY 1;

That groups by full hostname rather than registrable domain, which is close enough for a review trigger. If apply is false, keep serving the old list, alert someone, and show them problems and collateral. When collateral contains a domain that is genuinely disposable (your "active users" were throwaway accounts), apply the update by hand. When it contains a real company, add it to your allowlist and consider opening an issue upstream.

The thresholds are judgment calls. The real list grew by close to half between April and July 2026, but across 105 commits, the largest of which added about 800 domains, so a daily update never comes near 50%. A weekly or monthly job should loosen MAX_GROWTH accordingly.

Where you store the list depends on your deployment. On long-lived servers, an in-memory Set refreshed on a timer is fine; the Node.js and Express guide has a version with ETag handling and a bundled fallback. On serverless platforms, run the update as a scheduled job and write the result to a shared cache or KV store; caching domain lookups covers keys and TTLs.

Cover the gap a list can never close

Even a list updated daily lags behind a service that registers a domain this morning. Two cheap checks narrow the gap:

If you'd rather outsource the whole thing, compare open lists and hosted checkers in disposable email checker APIs: open lists vs paid services. pingutil is one hosted option: it syncs the open list weekly and exposes the result as the disposable category, but a weekly sync is itself a few days behind a daily one, and we don't run the user-collateral check above for you, because we don't know who your users are.

Freshness checklist

  • The list in production is fetched at runtime or by a scheduled job, at least weekly.
  • Updates that shrink sharply or arrive empty are rejected automatically.
  • New entries that match active users are held for review, not enforced.
  • Local allowlist and denylist overlays exist and are applied after every update.
  • Each applied update records its ETag or commit SHA.
  • Any bundled copy is a cold-start fallback, and you know how old it is.

Questions people ask

How often should I update my disposable email domain list?
At least weekly, and daily is easy if you fetch the list at runtime. The main open list receives commits most days, so a monthly update leaves you weeks behind.
Should I remove dead domains from a disposable blocklist?
There is little benefit. A domain that no longer resolves cannot receive your verification email anyway, and dead domains are sometimes revived. Keep them; they cost a few bytes each.
Can a disposable email blocklist block legitimate companies?
Yes. Lists occasionally contain typo domains or entries added by mistake, and community lists can be targeted by people trying to insert real domains. Check new entries against your existing users before you enforce them.