Last month I added a burner email filter to a signup form and within a day someone wrote in asking why their Apple Hide My Email address got rejected. They weren't abusing anything. They just didn't want to hand me their inbox.
That's the trap with disposable email lists. A relay like privaterelay.appleid.com or duck.com looks a lot like a throwaway service if you only ask "is this address hiding something?" The better question is "will this person still be able to read mail from me next week?" A relay forwards to an inbox the person keeps. A Mailinator address is public and gone tomorrow.
So the rule I ended up with has three parts:
- Allow known privacy relays first, before any block list runs.
- Block known disposable domains, including their subdomains.
- Collapse Gmail dots and
+tagsinto one key so one inbox can't open ten trials.
Everything below runs on plain Node 18 or later and drops into the App Router as is.
The classifier
// lib/signup-email.mjs
const DISPOSABLE = new Set([
"mailinator.com", "guerrillamail.com", "10minutemail.com", "tempmail.dev",
"yopmail.com", "trashmail.com", "sharklasers.com", "getnada.com",
]);
// Domains whose users pick aliases on purpose. Never block these.
const ALIAS_DOMAINS = new Set([
"privaterelay.appleid.com", "duck.com", "simplelogin.com", "simplelogin.co",
"anonaddy.me", "addy.io", "passmail.net", "mozmail.com",
]);
// Providers where dots and +tags don't change the inbox.
const DOT_INSENSITIVE = new Set(["gmail.com", "googlemail.com"]);
export function normalizeEmail(raw) {
const email = String(raw).trim().toLowerCase();
const at = email.lastIndexOf("@");
if (at < 1 || at === email.length - 1) return null;
let local = email.slice(0, at);
let domain = email.slice(at + 1);
if (domain === "googlemail.com") domain = "gmail.com";
if (DOT_INSENSITIVE.has(domain)) local = local.replace(/\./g, "");
// Keep the +tag for display, drop it for the uniqueness key.
const base = local.split("+")[0];
return { email, key: `${base}@${domain}`, domain };
}
function parentDomains(domain) {
const parts = domain.split(".");
const out = [];
for (let i = 0; i < parts.length - 1; i++) out.push(parts.slice(i).join("."));
return out;
}
export function classifyEmail(raw) {
const n = normalizeEmail(raw);
if (!n) return { ok: false, reason: "invalid" };
const chain = parentDomains(n.domain);
if (chain.some((d) => ALIAS_DOMAINS.has(d))) return { ok: true, kind: "alias", key: n.key };
if (chain.some((d) => DISPOSABLE.has(d))) return { ok: false, reason: "disposable" };
return { ok: true, kind: "standard", key: n.key };
}
A few choices worth explaining.
Aliases are checked first. If a relay domain ever lands on a community block list (it happens), your allow list still wins.
Parent domains are matched. Burner services love random subdomains like mx.yopmail.com. Walking up the domain chain catches those without a regex.
Dots only collapse for Gmail. At Gmail, jane.doe and janedoe are the same inbox. At Outlook and most company domains they are two different people, so the code leaves them alone.
The +tag is dropped only from the key. You still store and email the address the person typed. People use tags to filter mail and they notice when you strip them.
The disposable set here is deliberately short. In production, load a maintained list such as the disposable-email-domains repo on GitHub at build time and refresh it weekly.
The route handler
// app/api/signup/route.js
import { classifyEmail } from "@/lib/signup-email.mjs";
const seen = new Map(); // swap for a unique index on users.email_key
export async function POST(request) {
const { email } = await request.json().catch(() => ({}));
const result = classifyEmail(email ?? "");
if (!result.ok) {
const message = result.reason === "disposable"
? "Please use an inbox you plan to keep. Relay addresses like Hide My Email are fine."
: "That email doesn't look right.";
return Response.json({ error: message }, { status: 400 });
}
if (seen.has(result.key)) {
return Response.json({ error: "An account already uses this inbox." }, { status: 409 });
}
seen.set(result.key, email);
return Response.json({ created: true, kind: result.kind }, { status: 201 });
}
Two notes on the error copy. The disposable message tells people what is allowed, which cuts support mail. The duplicate message says "inbox" rather than "email", since the person typed a different string and might be confused otherwise.
The Map is a stand in. In a real app, add an email_key column with a unique index and let the database reject the second insert. With Supabase or plain Postgres:
alter table profiles add column email_key text;
create unique index profiles_email_key_idx on profiles (email_key);
Tests
Node ships a test runner, so no install is needed.
// signup.test.mjs
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyEmail } from "./signup-email.mjs";
import { POST } from "./route.mjs";
const post = (email) => POST(new Request("http://x/api/signup", {
method: "POST", body: JSON.stringify({ email }) }));
test("blocks disposable domains and their subdomains", () => {
assert.equal(classifyEmail("a@mailinator.com").reason, "disposable");
assert.equal(classifyEmail("a@mx.yopmail.com").reason, "disposable");
});
test("lets privacy aliases through", () => {
assert.equal(classifyEmail("x7f2@privaterelay.appleid.com").kind, "alias");
assert.equal(classifyEmail("shop.abc@duck.com").kind, "alias");
assert.equal(classifyEmail("me@mozmail.com").kind, "alias");
});
test("gmail dots and plus tags collapse to one key", () => {
assert.equal(classifyEmail("Jane.Doe+trial3@gmail.com").key, "janedoe@gmail.com");
assert.equal(classifyEmail("janedoe@googlemail.com").key, "janedoe@gmail.com");
});
test("dots stay meaningful outside gmail", () => {
assert.equal(classifyEmail("jane.doe@outlook.com").key, "jane.doe@outlook.com");
});
test("rejects junk", () => {
assert.equal(classifyEmail("nope").reason, "invalid");
assert.equal(classifyEmail("@x.com").reason, "invalid");
});
test("route: 201, then 409 for the same inbox, 400 for burners", async () => {
assert.equal((await post("jane.doe@gmail.com")).status, 201);
assert.equal((await post("janedoe+again@gmail.com")).status, 409);
assert.equal((await post("a@guerrillamail.com")).status, 400);
assert.equal((await post("hide@privaterelay.appleid.com")).status, 201);
assert.equal((await post(undefined)).status, 400);
});
$ node --test
✔ blocks disposable domains and their subdomains
✔ lets privacy aliases through
✔ gmail dots and plus tags collapse to one key
✔ dots stay meaningful outside gmail
✔ rejects junk
✔ route: 201, then 409 for the same inbox, 400 for burners
ℹ tests 6
ℹ pass 6
What this won't catch
A static list can't see a brand new burner domain registered this morning, and it says nothing about the IP or phone number behind the signup. If someone is scripting signups from one datacenter range with fresh domains, you'll need a signal beyond the email string. Rate limiting by IP prefix on the route is a cheap next step.
For most side projects though, this is enough to stop the casual trial farming and keep the privacy minded people who were going to pay you anyway.
I built Orisift for the parts this doesn't cover; there's a free tier. orisift.com
Top comments (0)