Emails (and pitfalls)
Validating email addresses with a regex is a famous trap. The RFC 5321 / 5322 grammar is huge and weird; almost no production regex captures it exactly. The right answer: a SIMPLE regex for shape, a DNS lookup for deliverability, and a confirmation email for proof of control.
Practical regex + DNS + confirmation
EXAMPLE
// 1) The full RFC grammar is impractical
// RFC 5321/5322 allows quoted local-parts, comments inside addresses,
// internationalised domains, IP-literal hosts, and more.
// The 'official' regex is ~6300 chars and still misses corner cases.
//
// You almost never want to validate RFC 5322 exactly. You want to confirm:
// 1) the SHAPE looks plausibly like an address
// 2) the DOMAIN exists and has MX (or A) records
// 3) the recipient confirms control via a magic-link email
// 2) A pragmatic shape check
const EMAIL = /^[\w.!#$%&'*+\/=?^`{|}~\-]+@(?:[A-Za-z0-9](?:[A-Za-z0-9\-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,}$/;
function validShape(s) {
if (typeof s !== 'string') return false;
if (s.length > 320) return false; // RFC max
if (s.split('@').length !== 2) return false;
return EMAIL.test(s);
}
validShape('mara@example.com'); // true
validShape('mara+tag@example.co'); // true
validShape('mara@localhost'); // false (no TLD)
validShape('mara@@example.com'); // false
validShape('mara@ex_ample.com'); // false (underscore not allowed in domain labels)
// 3) Why a tighter regex is risky
// • Disallows valid addresses with tags ($+$ in local-part)
// • Disallows valid TLDs (over 1500 registered)
// • Disallows internationalised email addresses (mara@münchen.de) — needs Unicode awareness
// • Disallows quoted local-parts ("john.doe"@example.com — rare but valid)
//
// Be conservative; reject obvious garbage, let the rest through to deliverability check.
// 4) Internationalised addresses (RFC 6531)
// Add Unicode letter / digit classes if you support EAI:
const EAI_EMAIL = new RegExp(
"^[\\p{L}\\p{N}.!#\$%&'*+/=?^`{|}~\\-]+@(?:[\\p{L}\\p{N}](?:[\\p{L}\\p{N}\\-]{0,61}[\\p{L}\\p{N}])?\\.)+[\\p{L}]{2,}$",
'u',
);
EAI_EMAIL.test('user@münchen.de'); // true (with /u flag and \\p{...})
// 5) Server-side library — preferred
import { validate } from 'email-validator-pkg'; // hypothetical; pick a maintained one
await validate('mara@example.com', { checkMx: true });
// In production, use:
// Node: validator.isEmail (basic), or @sideway/address, or email-validator
// Python: email-validator (PyPI, syntactically + DNS check)
// PHP: filter_var($email, FILTER_VALIDATE_EMAIL)
// Ruby: ActiveSupport's email regex or rfc6555 gem
// Rust: validator crate
// 6) DNS check — does the domain exist?
import { promises as dns } from 'node:dns';
async function domainHasMx(domain) {
try {
const mx = await dns.resolveMx(domain);
if (mx.length) return true;
} catch (e) { /* fall through */ }
try {
const a = await dns.resolve4(domain);
return a.length > 0; // RFC fallback: A record OK
} catch (e) {
return false;
}
}
await domainHasMx('example.com'); // true on the public internet
// 7) Disposable email blocklist
// • Stops most throwaway providers (10minutemail, guerrillamail, mailinator)
// • Maintained lists on GitHub (e.g. disposable-email-domains)
// • Use for signups where you want sticky users; allow legit subdomains explicitly
import { isDisposableEmail } from 'disposable-email-domains-js';
isDisposableEmail('mara@mailinator.com');
// 8) Plus-tag handling
// Some apps strip '+tag' before storing the address; others preserve it.
// • Gmail: 'user+tag@gmail.com' delivers to user@gmail.com
// • Many providers also normalise dots
// • If storing for dedup, normalise: lowercase + strip plus-tag + strip dots (Gmail-specific only)
function normaliseEmail(s) {
const [local, domain] = s.toLowerCase().split('@');
let l = local.split('+')[0];
if (domain === 'gmail.com' || domain === 'googlemail.com') l = l.replace(/\./g, '');
return `${l}@${domain}`;
}
normaliseEmail('Mara.A+tag@gmail.com'); // 'maraa@gmail.com'
// Be careful — don't normalise in transit; only for dedup, not for sending.
// 9) Length limits
// RFC 5321: max 320 chars total. Local-part: 64. Domain: 255. Enforce:
function validLength(email) {
if (email.length > 320) return false;
const [local, domain] = email.split('@');
return local.length <= 64 && domain.length <= 255;
}
// 10) Confirmation email — the only real proof
// Even the perfect regex + DNS lookup doesn't prove the user controls the inbox.
// Send a magic link / code with short TTL; require click-through before granting access.
import crypto from 'node:crypto';
async function sendConfirmation(email) {
const token = crypto.randomBytes(32).toString('base64url');
await db.confirmations.insert({ email, token, expiresAt: Date.now() + 24 * 60 * 60 * 1000 });
await mailer.send({
to: email,
subject: 'Confirm your email',
body: `Click to confirm: https://example.com/confirm?token=${token}`,
});
}
// 11) Common email patterns to handle (or reject explicitly)
// • Plus tags: valid
// • Dots in local: valid (Gmail folds them, others don't)
// • UPPERCASE: case-insensitive in the domain, but the local-part is technically case-sensitive (rarely used)
// • Quoted local: "hello world"@example.com — valid but rejected by many APIs
// • IP literal: user@[192.0.2.1] — valid; usually rejected for UX reasons
// • IDN domain: user@münchen.de — valid in EAI; convert to Punycode for DNS
// • Subaddressing: user.name+filter@example.com — same as plus tag
// 12) Avoid these regex anti-patterns
// • /^.+@.+\..+$/ — matches '@.@.' and other garbage
// • Long lists of TLDs — always wrong as ICANN adds new ones
// • Anchoring with /m flag — multi-line matching makes injection easier
// • Greedy quantifiers without anchors — backtracking risk on adversarial input
// 13) Performance + DOS
// • Catastrophic backtracking — keep the regex linear; avoid (a+)+ style patterns
// • Cap input length BEFORE running the regex (e.g. 320 chars max for emails)
// • For untrusted input at scale, use RE2 (Go, Rust) — guaranteed linear runtime
// • Run a unit test with property-based fuzzing (fast-check, hypothesis) on the validator
// 14) UX checklist
// • Validate on blur, not on every keystroke
// • Show actionable errors ('Did you mean gmail.com?') — use a typo-correction library (mailcheck.js)
// • Allow paste; trim whitespace; lowercase by default; preserve original on display
// • Don't tell the user the email DOESN'T exist (privacy) — say 'we'll send a code if it's registered'
// • Always re-verify on email change (re-send confirmation)
// 15) Common bugs
// • Overly strict regex rejects legit addresses (Gmail with dots, .au TLD when only [a-z]{2,3} accepted)
// • Mixing case-sensitive comparison in lookups — normalise to lowercase before storing
// • Forgetting subdomain support in domain part (sub.example.com)
// • Trusting client-side validation only — always re-check server side
// • Confirming via 'click here' that uses GET — pre-fetchers may auto-confirm; use POST + token
// • Letting the regex itself become an injection surface — never interpolate user-controlled regex fragments
Why it matters
Don’t try to validate RFC 5322 with a regex — use a forgiving shape check, then a DNS MX lookup, then a confirmation email. That three-step pattern handles internationalised addresses, future TLDs, and the long tail of valid quirks while still keeping obvious garbage out, and it produces the only signal that actually matters: the recipient controls the inbox.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
// Pragmatic email check (not RFC-perfect — that's almost impossible by regex): const basic = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; // For high-stakes use, prefer sending a verification email instead of stricter regex.Try it Yourself »
Discussion
Loading…