Making an account on Nakodo with a 10 minute mailbox gets you this:
That's a temporary email address. Use a permanent one, so you don't lose the account or miss a reply.
The second half of that sentence is the whole reason the check exists. Nakodo writes to creators on a brand's behalf and relays what comes back, so the account's email address is where the answers land. An account whose inbox expires in ten minutes is an account that cannot be told anything, including that somebody said yes.
The implementation is one file, src/lib/email/disposable.ts, 28 lines including the comments. This post is all of it, the measurement that proves the expensive part stays on the server, and the two things I would now change.
Two lists, 121,969 domains, 2.4 MB
import exact from "disposable-email-domains";
import wildcard from "disposable-email-domains/wildcard.json";
// Known throwaway email providers, so accounts can't be made by the thousand
// from inboxes nobody owns. The lists are large (120k domains), so this stays
// out of anything a browser loads: only the sign-up action imports it.
// Neither list ships types, and both are plain arrays of lowercase domains.
const exactDomains = new Set(exact as unknown as string[]);
// These match their subdomains too, e.g. `inbox.mailinator.com`.
const wildcardDomains = new Set(wildcard as string[]);
Measured from the installed package:
exact 121,570 domains index.json 2,460,096 bytes
wildcard 399 domains wildcard.json 6,460 bytes
The two lists answer two different questions. The big one is "is this exact domain a throwaway provider". The small one is "is this domain, or anything under it, a throwaway provider", which matters because a mailbox service that gives everybody a subdomain would otherwise need an entry per customer.
The as unknown as string[] on the first import is one of ten in the repo and the package is why: it ships no types, and a JSON array imported through a package main comes through as any-adjacent in a way a single assertion fixes honestly. I counted those casts in another post; this is the one I am least unhappy about.
The loop, and where it stops
export function isDisposableEmail(value: string): boolean {
const at = value.lastIndexOf("@");
if (at === -1) return false;
const domain = value.slice(at + 1).trim().toLowerCase();
if (!domain) return false;
if (exactDomains.has(domain)) return true;
// The domain, then each parent above it, so a listed base covers any depth
// below it. Stops before the bare suffix, which is nobody's provider.
const labels = domain.split(".");
for (let i = 0; i < labels.length - 1; i++) {
if (wildcardDomains.has(labels.slice(i).join("."))) return true;
}
return false;
}
Three details in nine lines of logic.
lastIndexOf("@"), not split("@"). An address may legally have an @ inside a quoted local part. Taking everything after the last one is right in every case where the address is valid, and this function is not the thing that decides validity.
The loop bound is labels.length - 1. For a.b.mailinator.com it tests a.b.mailinator.com, b.mailinator.com, mailinator.com, and then stops. Running to the end would test com, and a single bad entry in a 399 line list, or a future one, would refuse every address in the world ending in that suffix. The bound is the whole defence, so it got a comment.
Joining labels instead of using endsWith. endsWith(".mailinator.com") would also work, and endsWith("mailinator.com") would match zzqmailinator.com, which is a different company. Rebuilding the candidate from whole labels cannot make that mistake, and there is a test that says so:
test("a wildcard provider only matches whole labels", () => {
// Ends with the same letters as `mailinator.com` without being under it.
assert.equal(isDisposableEmail("sam@zzqmailinator.com"), false);
});
Five tests cover the file: the obvious providers, case insensitivity (SAM@Mailinator.COM), wildcard subdomains at two depths, the whole-label rule above, and this one:
test("what isn't an address is not this check's problem", () => {
for (const other of ["", "sam", "sam@", "@mailinator.com"]) {
assert.equal(isDisposableEmail(other), other === "@mailinator.com", other);
}
});
That last expectation looks wrong and is deliberate. @mailinator.com has no local part, so it is not an address, and this function says true anyway because the domain is listed. It never happens in production, because the check is a zod refine on a field that has already passed z.email(), so nothing malformed reaches it. The test is there to pin what the function does on its own rather than what the caller happens to prevent, which is the difference between a unit test and a guess.
The 2.4 MB is server-only, and here is the proof
The lists are imported by one module, which is imported by one Server Action file. I claimed in the comment that this keeps them out of the browser, and a comment is not evidence, so:
17 script chunks on the landing page, 1,120,437 bytes
grep for "mailinator" across all 17: 0 hits
grep for "disposable": 0 hits
Fetched from the live site a few minutes ago. If that import ever drifted into a client component, this is the check that catches it, and it is three lines of Python that I should have in a test rather than in a blog post.
On the server, the cost is paid once per instance at module load, measured on this laptop:
JSON parse of both lists 13.7 ms
building the two Sets 12.5 ms
heap after both 13.3 MB
Set rather than Array.includes is not a close call at 121,570 entries, and 13 MB of resident memory for a sign-up form is the kind of number that is fine right up until it is not.
Two things wrong with it
One: sign-in pays for sign-up's list. The refusal is only on new accounts, which is right: somebody who signed up with a throwaway address before we started checking can still sign in, confirm their email and reset their password, because the rule lives on a derived schema rather than in an if. That part I like, and it got written up in the zod post.
But the import is static and at the top of the module, so the 26 ms and the 13 MB are paid the first time anything in that file runs, and sign-in is in the same file. An await import() inside the refine would scope the cost to the one action that needs it, at the price of making the schema async. Worth it on a cold start, on a path a person is waiting on.
Two: the list refuses some addresses people keep. Spot checks:
| Domain | Refused |
|---|---|
| gmail.com, outlook.com, gmx.com, yandex.com, mail.ru, proton.me, icloud.com, fastmail.com, hey.com | no |
| mailinator.com, yopmail.com, 10minutemail.com, guerrillamail.com | yes |
| anonaddy.com, 33mail.com | yes |
| duck.com, simplelogin.com | no |
The bottom two rows are the problem. Those four are all alias forwarders: you get a unique address, mail arrives in a real inbox you keep. Two of them are on the list and two are not, and by the standard this check exists to enforce, none of them should be, because an alias is not an inbox that expires. Apple's Hide My Email lands on icloud.com and sails through, which is the same product with better distribution.
So the honest description of what we ship is: a 121,969 entry list, maintained by somebody else, that is right about throwaway mailboxes and inconsistent about privacy forwarders. The fix is a small allow list of alias services checked before the deny list, which is four lines and a judgment I am happy to make: an address that forwards to a mailbox somebody keeps is a permanent address, whatever the domain looks like.
The dependency floats (^1.0.62), so the list changes when it is installed, which is another reason the rule above belongs in our code rather than in our hope about theirs. You can try the refusal yourself on the sign-in page, which is also the sign-up page, and the message you get back is the one quoted at the top.
Top comments (0)