Why an email regex is never enough
· 6 min read · Email validation
An email regex can tell you that a string is shaped like an address. It cannot tell you that the domain exists, that it accepts mail, that the mailbox exists, or that the person typing it owns it. Use a short, permissive pattern for shape, add length checks, and put the real effort into DNS checks, domain classification and a confirmation email.
The rest of this post is about getting the shape check right, because most regexes in the wild are wrong in both directions: they reject addresses real people use and accept strings no mail server will take.
The RFC grammar is the wrong target
People reach for "RFC-compliant" as the goal. The message format grammar in RFC 5322 section 3.4.1 allows a lot:
- Quoted local parts:
"john doe"@example.comand"a@b"@example.comare valid. - Comments in parentheses, folded whitespace, and obsolete syntax kept for backwards compatibility.
- Domain literals:
jane@[192.0.2.1].
A regex that implements all of that is several kilobytes long, hard to review, and accepts addresses your mail provider, your CRM and your users' mail clients may choke on. It also doesn't enforce the limits that matter for actual delivery, which live in a different document: RFC 5321 caps the local part at 64 octets and the domain at 255.
The HTML spec authors made the same call. The WHATWG definition of a valid email address, used by every browser for <input type="email">, says outright: "This requirement is a willful violation of RFC 5322", because the RFC syntax is too strict before the @, too vague after it, and too lax in ways that would surprise users. Their pattern is:
/^[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])?)*$/
That's a better starting point than anything you'll find in a decade-old Stack Overflow answer. It is still only a starting point.
What the browser pattern gets wrong for a backend
Run the WHATWG pattern against a handful of edge cases and you get this:
| Input | WHATWG regex | Should a signup accept it? |
|---|---|---|
jane+tag@gmail.com |
match | Yes |
o'brien@example.ie |
match | Yes |
a..b@example.com |
match | No, consecutive dots are invalid in an unquoted local part |
.a@example.com |
match | No, leading dot |
a@localhost |
match | No for a public signup, single-label host |
jane@123.123.123.123 |
match | No, that's an IP address |
| 65-character local part | match | No, over the 64-octet limit |
jane@bücher.de |
no match | Yes, it's a real internationalized domain |
"john doe"@example.com |
no match | Rejecting it is fine |
The browser pattern is permissive on purpose: it's a hint for form input, and the browser can't know your context. On the server you can be stricter about dots, hosts and length, and more generous about Unicode domains.
A shape check you can actually ship
Split on the last @, check each half separately, and normalize the domain before matching. This version is Node 18+ with no dependencies:
import { domainToASCII } from 'node:url';
const LOCAL_RE = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+$/;
const LABEL_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
export function checkEmailShape(raw) {
const input = raw.trim();
const at = input.lastIndexOf('@');
if (at < 1 || at === input.length - 1) return { ok: false, why: 'missing_part' };
const local = input.slice(0, at);
const domainAscii = domainToASCII(input.slice(at + 1)); // lowercases, maps, punycodes; '' if invalid
if (!domainAscii) return { ok: false, why: 'bad_domain' };
if (new TextEncoder().encode(local).length > 64) return { ok: false, why: 'local_too_long' };
if (domainAscii.length > 253) return { ok: false, why: 'domain_too_long' };
if (!LOCAL_RE.test(local)) return { ok: false, why: 'bad_local' };
if (local.startsWith('.') || local.endsWith('.') || local.includes('..')) {
return { ok: false, why: 'bad_dots' };
}
const labels = domainAscii.split('.');
if (labels.length < 2) return { ok: false, why: 'single_label' };
if (!labels.every((l) => LABEL_RE.test(l))) return { ok: false, why: 'bad_label' };
if (/^\d+$/.test(labels.at(-1))) return { ok: false, why: 'numeric_tld' };
return { ok: true, email: `${local}@${domainAscii}` };
}
A few decisions baked in here, and why:
- Last
@, not first.a@b@c.comfails cleanly as a bad local part instead of producing a domain ofb@c.com. - The domain is converted before matching.
jane@Bücher.debecomesjane@xn--bcher-kva.deand passes. An ASCII regex on the raw input would reject a perfectly real domain. The conversion rules have sharp edges of their own; see internationalized email domains and punycode. - The local part keeps its case. RFC 5321 says the local part "MUST BE treated as case sensitive". In practice nearly every provider ignores case, so you'll want a lowercased copy for duplicate detection, but store what the user typed for sending.
- 253, not 255, for the domain. 255 octets is the wire-format limit, which includes length bytes; the dotted text form tops out at 253 characters.
- Quoted local parts are rejected. That's a product decision. If you run a mail client you need them. A SaaS signup form doesn't.
- Non-ASCII local parts are rejected.
josé@bücher.deis legal under SMTPUTF8 (RFC 6531), but every system that will ever send to it must support that extension, including your transactional email provider. Unless you've checked, reject with a clear message.
Plus addressing (jane+tag@) passes. Blocking + is a common way to annoy power users while doing nothing about abuse, since aliases are just as easy to create in other ways. If tagged aliases are a problem for your free trial, normalize them for duplicate detection instead; Gmail dots, plus addressing and duplicate accounts covers that.
Things no regex can know
Everything that passes the function above is still unverified. Some examples that sail through:
jane@gmai.com. A one-letter typo of a big provider. At the time of writing that domain publishes MX records, so even a DNS check passes it.jane@example.com. Valid shape, real domain, and it publishes a null MX record saying it never accepts mail. A DNS check catches this; a regex can't. See checking MX records to see if a domain can receive email.x7kq2mz9@mailinator.com. Perfect shape, working mail, and a disposable inbox that anyone can read.ceo@your-biggest-customer.comtyped by someone who isn't the CEO.
Each of those needs a different tool. Typos need a "did you mean" hint and, ultimately, a confirmation email. Dead domains need DNS. Disposable and machine-generated addresses need domain classification and local-part heuristics. Impersonation needs proof of ownership. The breakdown of which layer catches what is in email validation vs verification.
Layering the next check on top
Once the shape passes, the next step is cheap. Here's the shape check followed by a pingutil lookup in fast mode (no LLM enrichment) with DNS facts:
const shape = checkEmailShape(form.email);
if (!shape.ok) return { error: 'That doesn\'t look like an email address.' };
const res = await fetch('https://pingutil.com/v1/lookup', {
method: 'POST',
headers: {
authorization: `Bearer ${process.env.PINGUTIL_KEY}`,
'content-type': 'application/json',
},
body: JSON.stringify({ input: shape.email, skip_enrichment: true, check_dns: true }),
});
if (res.ok) {
const data = await res.json();
if (data.verdict.is_spam) return { error: 'Please use a permanent email address.' };
if (data.domain_signals?.domain_exists === false) {
return { error: `We couldn't find the domain ${data.domain_unicode}. Typo?` };
}
}
// API error or timeout: fail open and let the confirmation email do its job.
Using domain_unicode in the message means the user sees bücher.de, the thing they typed, rather than the punycode form.
Checklist for your email field
- Trim whitespace. Split on the last
@. - Convert the domain to ASCII with a real IDNA library, then lowercase it.
- Enforce 64 octets for the local part and 253 characters for the domain.
- Use a permissive local-part character set; reject leading, trailing and doubled dots.
- Require at least two labels and a non-numeric TLD.
- Don't block
+. - After shape: DNS, then classification, then a confirmation email.
If you want to see how a given address fares beyond its shape, try it in the live demo.
Questions people ask
- What is the best regex for validating email addresses?
- For most applications, the pattern from the WHATWG HTML spec that browsers use for input type=email is a sensible default. Add explicit length checks and reject leading, trailing and doubled dots in the local part, which that pattern allows.
- Should an email regex follow RFC 5322 exactly?
- No. RFC 5322 permits quoted strings, comments and other syntax that real users almost never type and that many mail systems handle badly. The HTML spec deliberately deviates from it for that reason.
- Does an email regex support international addresses?
- ASCII-only patterns reject internationalized domains like bücher.de unless you convert the domain to punycode first. Non-ASCII local parts need SMTPUTF8 support end to end, so most apps reject them on purpose.