"On the Internet, nobody knows you're a dog." — Peter Steiner, 1993. Thirty years later, nobody knows you're the same dog signing up for the fourth free trial.
Every product with a signup form eventually meets the same adversary: the disposable email address. Services like 10minutemail, Guerrilla Mail, and thousands of lesser-known clones hand out working inboxes that self-destruct minutes later. They're brilliant for privacy — and equally brilliant for trial abuse, referral fraud, review bombing, vote manipulation, and bot registration at scale.
doodad-labs/disposable-email-domains is my answer to that problem: a fully automated pipeline that aggregates, validates, and publishes a blocklist of disposable email domains — as of this posts publish date we are currently tracking 268,000+ flagged domains, 209,000+ root domains, and a daily-verified list of ~49,500 domains with live MX records. No manual curation, no stale lists. This post walks through why it exists, how the automation works under the hood, and how to actually use it.
Email verification is the cheapest identity check on the internet, which is exactly why bots learned to beat it first. A disposable address passes every naive check: it has valid syntax, the domain resolves, and the confirmation link genuinely gets clicked. From your application's point of view, it's indistinguishable from a real user — until the inbox evaporates and you're left with:
The standard defence is a domain blocklist. The standard failure is that the blocklist was last updated eight months ago. Disposable email providers churn constantly — they burn domains as fast as mail servers blacklist them and register fresh ones daily. A static list is obsolete the week it's published.
No single list keeps up, but many lists together, refreshed constantly, get close. The insight behind the project is that maintaining a blocklist shouldn't be an act of curation — it should be a build artifact.
The repository is split across two branches with very different jobs:
| Branch | Role |
|---|---|
main |
The published data: domains.txt, root.txt, active.txt + raw variants |
workflow |
The TypeScript automation that builds those files |
GitHub Actions runs the workflow branch code on a schedule and commits the output back to main as a bot. Humans only touch the repo to fix false positives.
Everything the aggregator ingests is defined in a single sources.json. It pulls from established community blocklists (disposable/disposable-email-domains, 7c/fakefilter, wesbos/burner-email-providers, StopForumSpam's toxic domain feeds, Laravel-Disposable-Email, and others) across three formats:
{
"blacklist": {
"txt": [ "https://raw.githubusercontent.com/.../domains.txt" ],
"json": [ { "url": "https://deviceandbrowserinfo.com/api/emails/disposable", "key": "." } ],
"csv": [ { "url": "https://raw.githubusercontent.com/.../TempEmailDomainMXRecords.csv", "col": 1 } ]
}
}
Adding a new intelligence source is a one-line pull request — no code changes. The JSON entries support dot-notation key paths and the CSV entries a column index, so almost any feed can be plugged in as-is.
Feeds are messy. Some wrap domains in quotes, some include full URLs, some mix in comments. Every line from every source goes through the same standardisation step: parse it as a URL, extract the hostname, lowercase it, or throw it away.
function standardiseDomain(domain: string): string | null {
try {
const url = domain.includes('://') ? new URL(domain) : new URL(`http://${domain}`)
return url.hostname.toLowerCase()
} catch {
return null
}
}
Because everything lands in a Set, duplicates across the dozen-plus sources deduplicate for free.
Aggregating other people's lists means inheriting other people's mistakes. Two safeguards run before anything is published:
psl.isValid(). Garbage entries and impossible hostnames never make it in.domain_whitelist.txt and tld_whitelist.txt on main act as a manual override. If a legitimate provider gets swept up by an upstream source, a one-line PR to the whitelist removes it from every future build:export function createWhitelistFilter(whitelist: Set<string>, tld_whitelist: Set<string>) {
return (domain: string): boolean => {
if (whitelist.has(domain)) return false
const parsed = psl.parse(domain);
if (parsed.tld && tld_whitelist.has(parsed.tld)) return false
return true
}
}
The TLD whitelist matters more than it looks: entire country-code TLDs used by legitimate regional providers can be protected in one line instead of thousands.
Disposable providers love subdomains — mail.tempbox.example, mx1.tempbox.example, and friends. The pipeline uses psl.get() to collapse every flagged hostname down to its registrable root, producing root.txt. If you block at the root level, every future subdomain a provider spins up is already covered.
A domain in a blocklist that no longer has mail infrastructure is dead weight — 200k+ entries is a lot to ship to a signup form if three-quarters of them can't receive email anyway. So once a day, a second job takes every root domain and asks a simple question: can this domain actually receive mail right now?
const mxRecords = await Promise.race([
dns.resolveMx(domain),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('DNS timeout')), timeoutMs)
),
]);
return mxRecords.length > 0 ? domain : null;
The checker runs 64 concurrent DNS workers with a 5-second timeout per domain, chewing through the full 209k root list in one scheduled run. ENOTFOUND, ENODATA, timeouts — anything that means "this domain can't receive email today" filters it out. The result is active.txt: the ~49,500 domains that are currently operational disposable providers. That's the high-signal list — small enough to load anywhere, fresh enough to trust.
Two GitHub Actions workflows drive the whole thing:
domains.txt and root.txtactive.txtEach workflow checks out the workflow branch, runs the script with tsx, then checks out main as a bot account and commits the generated files — data, raw variants, and shields.io-compatible badge JSON with the live counts. If nothing changed, nothing is committed. The repo's 1,800+ commits are almost entirely the bot doing its job.
The lists are plain text files, served from GitHub or the project's CDN:
https://dmails.doodadlabs.org/data/domains.raw.txt # everything, no comments
https://dmails.doodadlabs.org/data/root.raw.txt # root domains only
https://dmails.doodadlabs.org/data/active.raw.txt # only domains with live MX records
A minimal signup check needs about ten lines — load the list into a Set, extract the domain from the address, and compare against the registrable root so subdomain tricks don't slip through:
import psl from 'psl'
const res = await fetch('https://dmails.doodadlabs.org/data/root.raw.txt')
const blocked = new Set((await res.text()).split('\n'))
function isDisposable(email: string): boolean {
const domain = email.split('@').pop()?.toLowerCase() ?? ''
const root = psl.get(domain)
return root !== null && blocked.has(root)
}
Which list to use depends on where the check runs:
| List | Size | Best for |
|---|---|---|
domains.raw.txt |
268k+ | Server-side checks where memory is cheap |
root.raw.txt |
209k+ | Root-level matching that catches subdomains |
active.raw.txt |
~49.5k | Client-side or latency-sensitive checks |
Re-fetch on a schedule (hourly for the big lists, daily for active.txt) and your blocklist maintains itself the same way the upstream data does.
The point isn't that any of these techniques are novel — MX checks and blocklists are as old as email abuse. The point is composition: because ingestion, normalisation, validation, root extraction, liveness checking, and publishing are all automated, the list's freshness is bounded by a cron schedule instead of a maintainer's free weekend. When a disposable provider registers a new domain, it hits an upstream feed, and within hours it's flagged, validated, rooted, and published — with zero human involvement.
Blocking disposable emails won't stop a determined attacker; nothing at the email layer will. But it removes the cheapest abuse path, and most abuse is opportunistic. Raising the floor from "free throwaway inbox" to "real mailbox with a paper trail" filters out an enormous amount of noise for the cost of one Set.has() call.
Domain ecosystems churn — providers appear, vanish, and repurpose infrastructure weekly. The automation handles the churn, but community input keeps it honest:
domain_whitelist.txt (or a TLD to tld_whitelist.txt) and open a PR — the next build excludes it everywhere.sources.json on the workflow branch.The project is GPL-3.0 and lives at doodad-labs/disposable-email-domains.