The email regex copied from a decade-old answer rejects o'[email protected], [email protected], [email protected] and [email protected]ña — real addresses belonging to real customers, who are told their email is “invalid” and cannot sign up.
A client-side email check has a modest job: catch obvious typing mistakes quickly and help the user correct them. Whether an address exists can only be answered by sending mail to it. This page, part of synchronous validation patterns, replaces overreaching patterns with a permissive rule, adds typo suggestions that catch the mistakes people actually make, and places real verification where it belongs.
Context and prerequisites
The address formats that strict patterns commonly reject, all valid and in use:
- Plus addressing —
[email protected](Gmail, Fastmail, many providers). - Apostrophes and other punctuation in the local part —
o'brien@,first.last@,a_b-c@. - Long and new TLDs —
.museum,.photography,.london; patterns with{2,4}for the TLD fail these. - Internationalised domain names —
correo.españa(sent over the wire as punycodexn--espaa-rta), and internationalised local parts under SMTPUTF8. - Subdomains —
[email protected].
Meanwhile the mistakes users actually make are different: a missing @, a space, a trailing dot, a comma instead of a dot, and domain typos such as gmial.com or hotmail.con. A good client check targets those.
The core pattern: a permissive check plus typo suggestions
export type EmailCheck =
| { ok: true; normalised: string; suggestion?: string }
| { ok: false; message: string };
// One @, something before it, a dot somewhere after it with something on both
// sides, no whitespace. That is all a client can usefully require.
const SHAPE = /^[^\s@]+@[^\s@]+\.[^\s@.]+$/u;
const COMMON_DOMAINS = ["gmail.com", "yahoo.com", "hotmail.com", "outlook.com", "icloud.com", "aol.com", "proton.me"];
export function checkEmail(raw: string): EmailCheck {
const value = raw.trim();
if (value === "") return { ok: false, message: "Enter your email address." };
if (/\s/.test(value)) return { ok: false, message: "Email addresses cannot contain spaces." };
const at = value.split("@").length - 1;
if (at === 0) return { ok: false, message: "Enter an email address with an @, like [email protected]." };
if (at > 1) return { ok: false, message: "Enter an email address with only one @." };
if (value.endsWith(".")) return { ok: false, message: "Email addresses cannot end with a dot." };
if (!SHAPE.test(value)) return { ok: false, message: "Enter the part after the @, like example.com." };
const [local, domain] = value.split("@");
// Normalise ONLY the domain: domains are case-insensitive; local parts may not be.
const normalised = `${local}@${domain.toLowerCase()}`;
const suggestion = suggestDomain(domain.toLowerCase());
return { ok: true, normalised, suggestion: suggestion ? `${local}@${suggestion}` : undefined };
}
// Suggest a common domain if the typed one is within edit distance 2 (and not identical).
function suggestDomain(domain: string): string | undefined {
let best: { d: string; dist: number } | undefined;
for (const d of COMMON_DOMAINS) {
const dist = levenshtein(domain, d);
if (dist > 0 && dist <= 2 && (!best || dist < best.dist)) best = { d, dist };
}
// Also catch wrong TLDs on common providers: gmail.con, hotmail.co
const tldFix = domain.replace(/\.(con|cmo|comm|co)$/i, ".com");
if (!best && tldFix !== domain && COMMON_DOMAINS.includes(tldFix)) return tldFix;
return best?.d;
}
function levenshtein(a: string, b: string): number {
const dp = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
for (let j = 1; j <= b.length; j++) dp[0][j] = j;
for (let i = 1; i <= a.length; i++)
for (let j = 1; j <= b.length; j++)
dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
return dp[a.length][b.length];
}
A suggestion is not an error: show “Did you mean [email protected]?” with a button to accept it, and let the user submit their original if it is correct.
Step-by-step walkthrough
- Use
type="email"withautocomplete="email". Mobile keyboards show@and., and browsers autofill saved addresses — see autocomplete tokens for autofill-friendly forms. - Check shape, not spec compliance. One
@, a non-empty local part, a domain containing a dot, no whitespace. Specific messages for each common mistake are more useful than one generic “invalid”. - Trim, and lowercase only the domain. Leading and trailing spaces from copy-paste are never intended. Local parts are technically case-sensitive; most providers ignore case, but you should not change what the user typed there.
- Suggest, never auto-correct. A typo suggestion is a question. Silently changing
gmial.comtogmail.comwould be wrong for the user whose domain really isgmial.com. - Validate on blur. Email is typed in bursts; checking per keystroke shows “Enter the part after the @” while the user is still typing it. Use the timing in reward early, punish late.
- Confirm by sending mail. The only real validation is a confirmation link or code. Design the flow so an unverified address can be corrected easily.
Why “confirm your email” fields do not help
Asking users to type their email twice feels like it should catch typos, but people copy the first field into the second, or make the same slip twice, and the second field adds friction for everyone — especially on mobile and for people using switch access or voice input. A clear display of the entered address before submit (“We’ll send your receipt to [email protected] — change”), typo suggestions for common domains, and a confirmation email with an easy way to correct the address catch far more mistakes with less effort.
Failure modes and edge cases
1. The browser’s own type="email" validation
Native validation for type="email" uses a deliberately simple pattern defined in the HTML spec, which does not accept internationalised domains in Unicode form in every browser. With novalidate and your own rule you control this; without it, some browsers block [email protected]ña.
2. Disposable-domain blocklists
Blocking disposable email providers is a business decision with a cost: blocklists are incomplete and occasionally include legitimate providers. If you do it, do it on the server with a clear message, not as a client “invalid email”.
3. Uniqueness checks
“This email is already registered” is an async check, run after the shape passes — see implementing async email availability checks. Consider the privacy implication: it reveals which addresses have accounts.
4. Schema library defaults
z.string().email() and similar helpers use their own patterns, which have changed between versions and may reject some valid addresses. Test your library’s rule against the table above, and substitute a custom refine with the permissive check if needed.
5. Case-sensitive comparisons
If accounts are looked up by exact string match, [email protected] and [email protected] become two accounts. Normalise the domain everywhere, and decide deliberately whether to lowercase the local part for lookups.
Verification checklist
Frequently Asked Questions
Isn't there an official regex for email addresses?
The full RFC 5322 grammar allows quoted local parts, comments and other forms that no real user types, and a regex implementing it is enormous. The HTML specification defines a simpler “valid email address” pattern for type="email". Neither tells you whether an address works; only sending mail does.
Should I validate that the domain has MX records?
It is possible on the server with a DNS lookup and catches some typos, but DNS failures, slow resolvers and domains that accept mail without MX records (falling back to A records) cause false rejections. If you do it, treat failure as a warning, not a block.
Which common domains should the suggestion list include?
The ones your users actually use — check your existing sign-up data for the top domains and their frequent misspellings. A short, relevant list produces fewer wrong suggestions than a long generic one.