The safest email regex is a loose one: something, an @, something, a dot, something. Everything stricter rejects real people, and it still lets through the mistakes that actually cost you, like gmial.com. We ran 18 addresses through three regexes that developers copy every day, and through our own Email Validator API. Three of the four rejected at least one valid address, and ours rejected three.
Our own validator failed this test
On October 23, 2026, our Email Validator API answered conor.o'brien@outlook.com with an HTTP 400: email: must be a valid email address. The address is valid. Apostrophes are allowed in the part before the @, and plenty of people with Irish surnames have one there.
The validator itself never saw the address. Every APIVerve endpoint checks its parameters before the handler runs, and the check for parameters declared as emails is a regex in a shared helper:
const EMAIL_REGEX = /^([\w.+-]+)@((?:[\w-]+\.)*\w[\w-]{0,66})\.([a-z]{2,24}(?:\.[a-z]{2})?)$/i;
if (typeof email === 'string' && email.length > 5 && email.length < 61 && EMAIL_REGEX.test(email)) {
[\w.+-] allows letters, digits, underscore, dot, plus and hyphen, and nothing else, so the apostrophe fails. The length < 61 is worse. We probed it: an address of 60 characters returns 200, and one of 61 returns 400. The real limit is 254 characters for the whole address and 64 for the part before the @. Long work addresses with a department in the domain run past 60 more often than you’d think.
As of October 23 it still works this way. The fix is to replace that check with the loose one in the code below: the regex is rejecting addresses the validator itself would have accepted.
18 addresses, four validators
The three regexes are the “good enough” one-liner, the pattern that appears in countless tutorials, and the one the HTML spec uses for <input type="email">:
const simple = /^\S+@\S+\.\S+$/;
const common = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,4}$/;
const html5 = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
The format results first, with one extra address 61 characters long. “Pass” means the address got through.
| Address | What it is | simple | common | html5 | Our API (Oct 23) |
|---|---|---|---|---|---|
jane.doe@gmail.com | ordinary, valid | pass | pass | pass | pass |
JANE.DOE@GMAIL.COM | uppercase, valid | pass | pass | pass | pass |
jane+invoices@gmail.com | plus addressing, valid | pass | pass | pass | pass |
conor.o'brien@outlook.com | apostrophe, valid | pass | fail | pass | fail |
jane@mail.engineering | long TLD, valid format | pass | fail | pass | pass |
jane@dept.cs.example.ac.uk | deep subdomain, valid format | pass | pass | pass | pass |
jüri@gmail.com | non-ASCII, valid by RFC 6531 | pass | fail | fail | fail |
| 61-character Gmail address | valid | pass | pass | pass | fail |
jane@localhost | no dot in domain | fail | fail | pass | fail |
jane@gmail | missing TLD | fail | fail | pass | fail |
jane doe@gmail.com | space | fail | fail | fail | fail |
jane..doe@gmail.com | two dots in a row, invalid by RFC 5322 | pass | pass | pass | pass |
The common regex fails three valid addresses. Its {2,4} was written when top-level domains were short; .engineering, .photography and hundreds of others have been around since 2014. The HTML spec’s regex accepts jane@gmail on purpose, because an intranet address doesn’t need a dot, which is fine for a browser and wrong for a public signup form. The loose simple pattern fails nothing valid in this list.
Non-ASCII addresses are a judgment call. They’re valid, but Gmail and most providers won’t let you create one, so rejecting them costs very little today.
The regexes then pass every one of these, and so would any regex:
| Address | What’s wrong with it |
|---|---|
jane@gmial.com | typo of gmail.com |
jane@hotmial.com | typo of hotmail.com |
jane@gmail.co | typo of gmail.com |
jane@mailinator.com | disposable inbox |
jane@example.com | reserved domain, takes no mail |
jane@nonexistent-domain-zz9q.com | domain isn’t registered |
info@stripe.com | real, but a shared role inbox |
Format checks fight over edge cases that almost nobody types, and miss the mistakes people do type, because those are perfectly well-formed.
What a domain check catches
The Email Validator looks up the domain’s mail records. Here’s what it said about the second table:
{ "email": "jane@gmial.com", "isMxValid": false, "isDisposable": true, "suggestedCorrection": "jane@gmail.com" }
{ "email": "jane@gmail.co", "isMxValid": false, "isNullMx": true, "suggestedCorrection": "jane@gmail.com" }
{ "email": "jane@hotmial.com","isMxValid": true, "isDisposable": true, "suggestedCorrection": "jane@hotmail.com" }
{ "email": "jane@example.com","isMxValid": false, "isNullMx": true, "canReceiveMail": false }
{ "email": "info@stripe.com", "isMxValid": true, "isRoleAccount": true, "isDisposable": false }
Two of those look wrong at first glance.
gmial.com and hotmial.com come back as disposable. That isn’t a mistake in our lists. Typo domains like these get registered by people who want the mail meant for the real provider, so a signup that mistypes Gmail can send its welcome email, and later its password reset, to a stranger. The public disposable-domain lists we merge include them on purpose. hotmial.com has working mail servers, so a check that only asks “does the domain take mail?” says yes.
gmail.co and example.com have a “null MX” record: an explicit statement in DNS that the domain accepts no mail, ever. It’s the clearest answer DNS can give, and only a lookup sees it.
The API also returns normalizedEmail. For Gmail addresses it drops dots and anything after a plus, so jane+invoices@gmail.com and J.A.N.E@gmail.com both normalize to jane@gmail.com (we checked J.A.N.E+news@gmail.com too). Store it next to what the person typed and you can see the same inbox signing up for a second free trial.
A form handler that asks before it refuses
This is the decision logic we’d put behind a signup form.
// check-email.js: decide what a signup form should do with an address
const KEY = process.env.APIVERVE_API_KEY;
// In the browser, check only what a typo can break. Anything stricter rejects real people.
const LOOKS_LIKE_EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
async function checkEmail(email) {
email = email.trim();
if (!LOOKS_LIKE_EMAIL.test(email) || email.length > 254) {
return { action: 'reject', message: 'That doesn\'t look like an email address.' };
}
let v;
try {
const res = await fetch(`https://api.apiverve.com/v1/emailvalidator?email=${encodeURIComponent(email)}`, {
headers: { 'x-api-key': KEY },
signal: AbortSignal.timeout(5000), // odd DNS setups (typo domains) took up to 3.8 s in our tests
});
if (!res.ok) throw new Error(`validator returned ${res.status}`);
v = (await res.json()).data;
} catch (err) {
// Fail open. You'll find out about a bad address when the welcome email bounces.
return { action: 'accept', note: `unchecked: ${err.message}` };
}
// A likely typo: ask, don't refuse. suggestedCorrection is a paid field (null on free).
if (v.suggestedCorrection) {
return { action: 'confirm', message: `Did you mean ${v.suggestedCorrection}?` };
}
// The domain says it takes no mail at all, or has no way to receive it.
if (v.isNullMx || !v.canReceiveMail) {
return { action: 'reject', message: `${v.domain} can't receive email. Is there a typo?` };
}
// Disposable lists include typo domains like gmial.com on purpose, so the message covers both.
if (v.isDisposable) {
return { action: 'reject', message: `We can't accept ${v.domain} addresses. Is there a typo?` };
}
// Accept the rest. Store normalizedEmail next to what they typed to catch repeat signups.
return { action: 'accept', normalized: v.normalizedEmail, roleAccount: v.isRoleAccount };
}
module.exports = { checkEmail };
if (require.main === module) {
(async () => {
for (const e of process.argv.slice(2)) console.log(e.padEnd(28), JSON.stringify(await checkEmail(e)));
})();
}
The output on a paid key:
jane+invoices@gmail.com {"action":"accept","normalized":"jane@gmail.com","roleAccount":false}
jane@gmial.com {"action":"confirm","message":"Did you mean jane@gmail.com?"}
jane@gmail.co {"action":"confirm","message":"Did you mean jane@gmail.com?"}
jane@mailinator.com {"action":"reject","message":"We can't accept mailinator.com addresses. Is there a typo?"}
info@stripe.com {"action":"accept","normalized":"info@stripe.com","roleAccount":true}
jane@example.com {"action":"reject","message":"example.com can't receive email. Is there a typo?"}
jane@gmail {"action":"reject","message":"That doesn't look like an email address."}
On the free plan suggestedCorrection is always null, so the two typos fall through to the next rules and get rejected with a “typo?” message instead of a suggestion. The person still finds out before they leave the page.
The timeout is 5 seconds because 3 wasn’t enough. We timed repeated lookups: real providers answered in 0.3 to 0.9 seconds, but gmial.com took as long as 3.8, and a 3-second limit made the check give up on exactly the address it exists to catch.
When the check fails, the signup goes through, and a bad address shows itself soon enough as a bounce.
A likely typo gets a question. “Did you mean jane@gmail.com?” with one click to accept fixes the problem. “Invalid email” makes the person retype the same thing and leave.
Role addresses are accepted. info@ and billing@ are how small businesses sign up for things; flag them if a shared inbox matters to you, but don’t refuse a company’s billing address on a B2B product.
Two things not to build, one thing to keep
Don’t connect to the recipient’s mail server to ask whether a mailbox exists. Gmail and Outlook won’t tell you, catch-all domains say yes to everything, and mail providers treat repeated probes as spam behaviour, which can land your real email in spam later.
Don’t add a second regex on the server that’s stricter than the browser’s. That’s the mistake we made: two checks that disagree, where the stricter one quietly wins and the person only sees “invalid”.
And send a confirmation email anyway. Validation tells you an address can receive mail; only a click on a link tells you the person typing it owns the inbox.