Twenty-eight characters in, twenty-eight seconds of CPU out. The input is aaaaaaaaaaaaaaaaaaaaaaaaaaaa!, which is obviously not an email address, and the pattern grinding on it is the one OWASP publishes as its textbook example of a bad email regex. Add two more characters and the wait passes two minutes.
That is the real shape of this problem. Email validation looks like a one-liner and turns into a set of trade-offs: which legal addresses you reject, which illegal ones you wave through, and what happens when someone pastes hostile input on purpose. Every expression below was compiled and run against one list of test addresses on 10 August 2026, under Python 3.11.15 and Node v22.22.2. Every claim about a standard or a framework was read from the source.
What a Regex Can Decide
A regular expression answers one question: is this string shaped like an address. It cannot tell you whether the mailbox exists, whether the domain accepts mail, or whether a human will open it. Perl's own FAQ has said so for two decades: perlfaq9 advises having the user type the address twice, "just as you normally do to change a password", then "send mail to that address with a personal message".
The formal grammar is far more permissive than anyone expects. RFC 5322 section 3.4.1 defines local-part = dot-atom / quoted-string / obs-local-part, and quoted-string wraps folding whitespace, so "john doe"@example.com is a legal address no provider on earth will sell you. A pattern that is technically correct against the grammar accepts things nobody can use, and rejecting them costs you nothing.
Three documents are worth reading directly:
- RFC 5322, Internet Message Format, the current message-format standard: https://www.rfc-editor.org/rfc/rfc5322
- RFC 5321, SMTP, which defines transport rules, mailbox semantics and the size limits: https://www.rfc-editor.org/rfc/rfc5321
- RFC 3696, Application Techniques for Checking and Transformation of Names, John Klensin's readable summary: https://www.rfc-editor.org/rfc/rfc3696
The Limits Nobody Encodes
Every pattern in this article ignores length, and length is where the standard is unambiguous. RFC 5321 section 4.5.3.1 states it in one line each. "The local part of a mailbox MUST NOT exceed 64 octets." "The domain name MUST NOT exceed 255 octets." "The maximum total length of a reverse-path or forward-path is 256 octets." The path travels inside angle brackets in MAIL FROM:<...>, so the address itself gets 254.
That 254 is the number the internet keeps getting wrong. RFC 3696 originally said 320, adding 64 and 255 and a separator. Two verified errata now sit against that sentence: errata 1003 cut it to 256, and errata 1690 cut it again to 254, because the path budget also covers the brackets. Django still carries the uncorrected figure. Its django/core/validators.py comment reads "The maximum length of an email is 320 characters per RFC 3696 section 3", and the Django 5.2 documentation repeats it verbatim.
A 65-octet local part passes every single pattern in this article. Check lengths in code before the regex runs, and you also cap the input your regex has to chew on.
One more rule regexes cannot express. RFC 5321 section 2.4 says the local part "MUST BE treated as case sensitive" and that implementations "MUST take care to preserve the case of mailbox local-parts". Domains are DNS names and are not. Every large provider folds case on delivery, so lowercasing for storage is safe, and lowercasing before validation is what makes the case-folded pattern below usable at all.
Tools for Testing Your Patterns
Test against a representative sample before deploying anything, and put the awkward cases in the sample deliberately. Four browser-based testers came up in this research:
- regex101 covers nine grammars in one box: PCRE2, JavaScript, Python, Go, Java, .NET, Rust, POSIX ERE and POSIX BRE, with a live explanation pane and a step debugger: https://regex101.com/
- RegExr, built by gskinner, does JavaScript and PHP/PCRE only, with a searchable community pattern library: https://regexr.com/
- Regex Tester belongs to Dan's Tools, and its own page credits the engine underneath: "Powered By RegExr". Testing a pattern here and then in RegExr proves nothing: https://www.regextester.com/
- RegexLib advertises "4149 expressions from 2818 contributors around the world" under a footer dated 2001-2026. Quality varies, so read the description and test before adopting: https://regexlib.com/
Flavour matters more here than in most regex work, because email patterns lean on \w and on Unicode behaviour, which differ between engines. We compared testers separately in best online regex testers, and Debuggex draws your pattern as a railroad diagram.
Two of those four run the same engine.
General-Purpose Patterns
The everyday workhorses, deliberately simpler than the grammar, trading completeness for predictability.
A minimal, readable pattern
Accepts the common local-part characters and a dotted domain. It does not support the bracketed IP-address form such as user@[192.168.0.1]:
^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$It is also the loosest pattern here in one way. The final class [a-zA-Z0-9-.]+ accepts dots anywhere, so user@example.com., user@-example.com and user@a..b all pass. For a form field that only separates a typo from an address, fine. For a value going into a mail queue, not fine.
A case-folded variant
Matches lowercase only, so normalise the input before you validate:
^[_a-z0-9-]+(\.[_a-z0-9-]+)*@[a-z0-9-]+(\.[a-z0-9-]+)*(\.[a-z]{2,6})$This one has aged badly, in two ways that matter. The local-part class has no +, so it rejects user+tag@gmail.com. Google documents plus addressing as a feature: "Just add a plus sign (+) and any word before the @ sign in your current address," with cassy+news@solarmora.com as the example on its own support page. Rejecting subaddressed mail is not strictness, it is a bug report waiting to be filed.
The second problem is {2,6}. That cap was written when top-level domains were short. The IANA root zone list carries the header "Version 2026081000, Last Updated Mon Aug 10 07:07:01 2026 UTC". Among its entries are .photography, delegated in 2013 at eleven letters, and .travelersinsurance at nineteen. Any length ceiling on the TLD is a slow leak, and the only safe value is none.
The pattern does reject bare-domain addresses such as nathan@localhost. Whether that is a feature depends on whether your application talks to a local mail transfer agent.
A pattern for finding addresses in text
This one admits the wider set of specials the standards allow in a local part, and the \b word boundaries make it a finder rather than a validator. Use it to pull addresses out of a document, not on a single form field:
/\b[!#$%&'*+\-/=?^_`{|}~a-zA-Z0-9][!#$%&'*+\-/=?^_`{|}~a-zA-Z0-9.]*[!#$%&'*+\-/=?^_`{|}~a-zA-Z0-9]@[a-zA-Z0-9\-][a-zA-Z0-9\-.]+[a-zA-Z0-9\-]\b/gThe trailing boundary earns its keep: given Write to sales@example.com.</p> it stops before the sentence-ending dot. What it does to real page markup gets its own section further down.
What the Patterns Actually Accept
Descriptions of regexes are cheap. We compiled five of these patterns and ran them against one address list on 10 August 2026 under Python 3.11.15. Anchors were added where a pattern lacked them, so every column answers one question: does this match the whole string.
| Address | minimal | case-folded | "RFC 5322" | ASP.NET | HTML5 |
|---|---|---|---|---|---|
simple@example.com |
yes | yes | yes | yes | yes |
user+tag@gmail.com |
yes | no | yes | yes | yes |
shop@example.photography |
yes | no | yes | yes | yes |
o'brien@example.com |
no | no | yes | yes | yes |
a`b@example.com |
no | no | no | no | yes |
"john doe"@example.com |
no | no | no | no | no |
nathan@localhost |
no | no | no | no | yes |
admin@[192.168.0.1] |
no | no | yes | no | no |
user@xn--80ak6aa92e.com |
yes | yes | yes | no | yes |
user@-example.com |
yes | yes | no | no | no |
john..doe@example.com |
yes | no | no | no | yes |
| 65-octet local part | yes | yes | yes | yes | yes |
Bold marks the answer that will surprise someone. The simple expressions are too loose about the domain and too tight about the local part; the standards-derived ones get the domain right and still cannot handle a quoted string. The apostrophe row costs you every Irish surname on a signup form. The punycode row is every internationalised domain on the internet, and the ASP.NET pattern refuses all of them. The last row is the one nobody notices, because it fails in the mail queue three days later.
Standards-Compliant Patterns
If you genuinely need to honour the formal grammar, the expression grows dramatically, which is itself the strongest argument against doing it.
The regex everyone calls "RFC 5322"
This is the pattern that circulates as the authoritative one. It comes from emailregex.com, still live and still listing it under the heading "RFC 5322 Official Standard". The site claims it works for 99.99% of addresses, without publishing the dataset behind that number:
/(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/gA correction against the earlier version of this article. The copy printed here previously had the backtick missing from the local-part character class, which quietly turned a legal address into an invalid one. Transcribing a 400-character regex by hand is how that happens, and it argues for pasting from the source rather than from a blog post, this one included.
Know two things before trusting the name on the tin. It rejects "john doe"@example.com, because the quoted-string class runs [\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f] and \x20, the space, is not in it. RFC 5322 allows folding whitespace inside a quoted string, so the address is legal and the "official standard" regex says no. And inside the IPv6 branch the class reads \x21-\x5a\x53-\x7f, whose two ranges overlap. The parallel class one line earlier uses \x5d-\x7f, which is what the branch needs to keep the bracket, backslash and closing bracket out. A single-character slip, copied for well over a decade.
It does handle the bracketed IP-literal domain form the simpler patterns omit. A genuine capability, and almost never a requirement.
The generated monster
The famous enormous expression was never written by hand. It was generated, by stitching together sub-expressions that map onto the productions of the original grammar. The generator is the Perl module Mail::RFC822::Address by Paul Warren, whose own page concedes that "implementing validation with regular expressions somewhat pushes the limits of what it is sensible to do with regular expressions".
- Module on CPAN: https://metacpan.org/dist/Mail-RFC822-Address
- Author's project page: https://pdw.ex-parrot.com/Mail-RFC822-Address.html
Both are still up in August 2026, and both are museum pieces. The newest release CPAN has indexed is 0.3, dated 13 April 2002, while the author's own page still points at a 0.4 tarball. Nothing is broken and nothing is moving, and RFC 822, the grammar it validates against, has been obsolete since 2001.
The best-known portable rendering of the same approach is Cal Henderson's PHP implementation at https://code.iamcal.com/php/rfc822/, at version 11 with a conformance test suite. The page is candid about its own age: "This library was named back when there was only one RFC for email addresses; there are now lots, so it would be better named RFC 822/2822/5322 at the least."
A short history of the relevant RFCs
| Document | Year | Status on 10 August 2026 |
|---|---|---|
| RFC 822 | 1982 | Obsoleted by RFC 2822 |
| RFC 2822 | 2001 | Obsoleted by RFC 5322 |
| RFC 5322 | 2008 | Current message format, Draft Standard, updated by RFC 6854 |
| RFC 5321 | 2008 | Current SMTP, Draft Standard, updated by RFC 7504 |
| RFC 6531 / RFC 6532 | 2012 | SMTPUTF8 transport and non-ASCII headers |
| RFC 7505 | 2015 | Null MX, "this domain accepts no mail" |
RFC 6854, from March 2013, updates RFC 5322 rather than replacing it, allowing group syntax in From: and Sender:. A library advertising itself as "RFC 2822 compliant" is describing a document obsolete for eighteen years. Hibernate Validator's @Email implementation still cites RFC 2822 in its source comments, at version 9.1.3.Final, published 26 July 2026 according to Maven Central metadata.
The next revision is stuck, and that is worth knowing. The IETF EMAILCORE working group has been preparing successors meant to advance both documents to full Internet Standard. On 10 August 2026, draft-ietf-emailcore-rfc5321bis sits at revision 44, dated 31 July 2025, in the RFC Editor queue with state "Blocked". draft-ietf-emailcore-rfc5322bis sits in the same queue at revision 12, dated 13 June 2024. Anything you write against RFC 5322 today stays current.
Environment-Specific Patterns
Many platforms ship a default. Reusing the one your stack already applies keeps client and server in agreement, which turns out to be harder than it sounds.
JavaScript- and Perl-compatible
/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/This is often described as the WHATWG pattern. It is close, and the difference is in the domain. The real HTML5 expression bounds every label at 63 characters and requires each to start and end with an alphanumeric. This one does neither, so user@-example.com passes here and fails in a browser. A gap between what your field accepts and what the browser accepts is what produces support tickets nobody can reproduce.
ASP.NET
The pattern historically used by the RegularExpressionValidator control in ASP.NET Web Forms:
\w+([-+.']\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*Same string, two answers. \w is not a fixed character set. Microsoft's own reference on character classes documents the default as [\p{Ll}\p{Lu}\p{Lt}\p{Lo}\p{Lm}\p{Mn}\p{Nd}\p{Pc}], and as [a-zA-Z_0-9] when RegexOptions.ECMAScript is set. JavaScript's \w is permanently the ASCII version. We ran the pattern against Pelé@example.com in both: Python 3.11.15, whose \w is Unicode-aware like .NET's, matched it; Node v22.22.2 did not. The reference for the control warns about exactly this: "On the client, JScript regular-expression syntax is used. On the server, System.Text.RegularExpressions.Regex syntax is used."
A second problem has no upside at all. \w+([-.]\w+)* cannot match two consecutive hyphens, and every internationalised domain on the internet is punycode beginning xn--.
The stack itself has moved on. RegularExpressionValidator lives in System.Web, and the API reference lists it for .NET Framework 4.8.1 only. What modern .NET ships instead is EmailAddressAttribute, and its IsValid in dotnet/runtime contains no regular expression. Stripped of null and type handling, the whole check is this:
if (valueAsString.AsSpan().ContainsAny('\r', '\n'))
{
return false;
}
int index = valueAsString.IndexOf('@');
return
index > 0 &&
index != valueAsString.Length - 1 &&
index == valueAsString.LastIndexOf('@');One @, not first, not last, no line breaks. A platform that once shipped a 60-character regex now ships four comparisons. The reasoning is not hard to reconstruct: the regex rejected valid addresses, never caught an invalid one that mattered, and the confirmation email does the real work.
HTML5 / WHATWG
The rule the browser applies to <input type="email"> is defined in the WHATWG HTML Standard. MDN publishes the equivalent expression, which in case-sensitive form reads:
/^[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])?)*$/This is not a paraphrase. Chromium carries it in email_input_type.cc as two C string literals, one commented // local part and one // domain part, beside a URL pointing back at the same spec section. When the multiple attribute is present it splits on commas and validates each piece. Ruby's standard library copies the expression verbatim into URI::MailTo::EMAIL_REGEXP, under the label # practical regexp for email address and the same spec URL. Three independent implementations, one expression.
The specification is open that its rule departs from RFC 5322 deliberately. The committee judged the grammar too strict before the @, too vague after it, and too tolerant of comments, whitespace and quoted strings to be useful in a web form. The result is unintuitive in both directions. The domain part is stricter than most hand-rolled patterns, with the 63-character label bound and no leading or trailing hyphens. The local part is looser than the RFC, because the class before the @ contains the dot itself rather than treating it as a separator between atoms. john..doe@example.com and .john@example.com both pass, and neither is a legal dot-atom.
And the domain dot is optional. The trailing * means zero label groups after the first, so nathan@localhost is a valid e-mail address as far as every browser is concerned. Django lands in the same place by another road, shipping ['localhost'] as the default allowlist on its EmailValidator.
Internationalized (German) example
To take German umlauts in either part of the address, the widely copied pattern is:
/([-a-z0-9öäüÖÄÜ.]+)@((?:[-a-z0-9öäüÖÄÜ.]+\.)+)([a-zA-Z]{2,4})/giIt teaches the wrong lesson twice. The {2,4} ceiling is tighter still than the {2,6} above, so it rejects .museum, .travel and everything delegated since 2013. And umlauts never reach the wire as umlauts. A domain with non-ASCII characters travels as punycode, which is why xn-- prefixes exist and why ASCII-only patterns still work on internationalised domains once the conversion has happened. A non-ASCII local part is a separate mechanism needing the SMTPUTF8 extension from RFC 6531, supported by your submission library and by every relay on the path. Use a library that converts the domain to A-label form and tells you whether SMTPUTF8 is required.
What the Frameworks Do in 2026
Versions and dates below were read from each project's own registry or repository on 10 August 2026.
| Stack | What it actually does | Version checked |
|---|---|---|
HTML5, Chromium, Ruby URI::MailTo |
One shared regex. ASCII only, domain dot optional, consecutive dots allowed in the local part | HTML Standard, living |
Django EmailValidator |
Separate local and domain regexes, quoted strings accepted, localhost allowlisted, 320-character cap |
Django 5.2 |
validator.js isEmail |
64-octet local and 254-octet total checks, UTF-8 local parts on by default, plus a Gmail branch that strips subaddresses and dots and enforces 6 to 30 characters | 13.15.35, 2 April 2026 |
Zod z.email() |
A Gmail-shaped regex by default, with z.regexes.html5Email, rfc5322Email and unicodeEmail as opt-ins |
zod 4.4.3, 4 May 2026 |
PHP filter_var, egulias/email-validator |
The manual does not say which grammar FILTER_VALIDATE_EMAIL implements; the library uses real parsers, selectable per RFC |
egulias 4.0.4, March 2025 |
.NET EmailAddressAttribute |
No regex. One @, not first, not last, no CR or LF |
dotnet/runtime, main |
Go net/mail |
A parser following RFC 5322 as extended by RFC 6532; refuses obsolete forms and route addresses | Go standard library |
Python email-validator |
Parser plus optional MX lookup, normalisation, and an allow_smtputf8 switch |
2.3.0, 26 August 2025 |
Perl Email::Valid |
Parser plus optional MX check, including null-MX detection | 1.204, 20 January 2024 |
The direction of travel is one-way. Where a stack once shipped a regex, it now ships a parser, a set of octet checks, or four comparisons and a confirmation email. Zod is the interesting middle case: its default is neither the RFC nor the browser rule but a pattern its documentation calls "roughly equivalent to the rules enforced by Gmail".
None of this has displaced the old npm package email-validator, whose latest release, 2.0.4, was published on 27 May 2018. In the month ending 9 August 2026 it was downloaded 9,672,165 times. Eight years without a release and ten million installs a month is a fair summary of how much the ecosystem cares.
Where This Breaks: Catastrophic Backtracking
When a pattern lets the same input be split several ways and the match then fails, a backtracking engine tries every split before giving up. The number of splits can grow exponentially with input length. The email regex on OWASP's ReDoS page is the canonical demonstration:
^([a-zA-Z0-9])(([\-.]|[_]+)?([a-zA-Z0-9]+))*(@){1}[a-z0-9]+[.]{1}(([a-z]{2,3})|([a-z]{2,3}[.]{1}[a-z]{2,3}))$The dangerous part is (([\-.]|[_]+)?([a-zA-Z0-9]+))*: a repeated group whose separator is optional, so a run of letters can be carved up in exponentially many ways. Matching against n letters followed by an exclamation mark, so the match always fails:
n = 20 0.12 s
n = 22 0.54 s
n = 24 2.14 s
n = 26 7.09 s
n = 28 28.41 sMeasured on one machine with Python 3.11.15 on x86_64, median of three runs, using the re module. Absolute numbers depend on machine and engine. The doubling does not.
Roughly a factor of four per two characters. Extrapolate and a 40-character string is measured in days. One POST body is enough, and no botnet is required.
Three fixes, in order of preference. Rewrite the pattern so the ambiguity is gone, which usually means making separators mandatory between repeated atoms. Failing that, forbid backtracking where it cannot help. Python gained atomic groups (?>...) and possessive quantifiers *+, ++, ?+ and {m,n}+ in version 3.11, and wrapping the offending group turns every row of that table into roughly two microseconds. .NET has carried RegexOptions.NonBacktracking since .NET 7, guaranteeing linear time. Third, set a timeout, while noting the caveat in Microsoft's regex best practices: "Time-out values are not intended as a security boundary against malicious patterns."
Anchors are a performance feature
This one is easy to miss, because the pattern looks harmless. An unanchored regex that fails does not fail once. It fails at every starting offset in the string.
Take the ASP.NET pattern above, which has no nested quantifier trouble at all, and feed it a long run of letters with no @ anywhere:
input unanchored anchored
1,000 35.7 ms 0.047 ms
2,000 102.3 ms 0.094 ms
4,000 482.0 ms 0.176 ms
8,000 2,099.6 ms 0.355 msSame machine and method. The unanchored column roughly quadruples as the input doubles, the signature of quadratic behaviour; the anchored column doubles, which is linear.
Eight kilobytes of junk in a form field is two seconds of one CPU. A hundred concurrent submissions is your web tier. Anchor with ^ and $, or \A and \z where the language distinguishes them, and cap the field length before the regex sees it.
Why Browsers and the Standards Disagree
The HTML5 email input is the clearest case of vendors defining "valid email" on their own terms, and the divergence is not an oversight. It is a documented decision, restated every time a browser ships.
The consequence lands on users with non-ASCII names. An address RFC 6532 makes perfectly legal, with an internationalised local part, is rejected by every browser, because the pattern all three engines implement is ASCII-only. The user gets no explanation, because form validation has none to give.
This has been an open question for seven years. The WHATWG issue asking for internationalised address support, whatwg/html#4562, was opened on 24 April 2019 and is still open on 10 August 2026. The pull request that would implement it, whatwg/html#10522, was opened on 25 July 2024, last saw activity in October 2025, and is also still open. Review comments pushed it away from a regex and toward an algorithm with Unicode NFC normalisation and domain-to-ASCII conversion, which is the right answer and also why it is taking years.
There is no second opinion to appeal to any more. A request to www.w3.org/TR/html52/sec-forms.html answers 302 and redirects to the WHATWG standard. One document, one regex, one open issue.
Until that changes, treat browser validation as a typo filter. It runs before your code and cannot be trusted by it. MDN says so directly: "It's far too easy for someone to make adjustments to the HTML that allow them to bypass the validation, or to remove it completely."
Harvesting Addresses Is Not Validating Them
Everything above assumes one string in one field. Pulling addresses out of scraped pages is a different job with a different failure profile, and the pattern that validates well is the wrong tool for finding. Run the finder pattern from earlier over ordinary markup. This snippet contains one address a human would call an address:
snippet = ('<p>Write to sales@example.com.</p>'
'<img srcset="/img/logo@2x.png 2x">'
'<code>npm i lodash@4.17.21</code>'
'<p>support@example.com</p>'
'<span style="display:none">trap@example.com</span>')Applied to the raw string, the pattern returns sales@example.com, img/logo@2x.png, lodash@4.17.21 and trap@example.com. Run html.unescape first and support@example.com appears too. Two false positives and one honeypot out of five hits.
Entities first, regex second. @, @ and @ are all the @ character, and a pattern looking for a literal @ sees none of them. Decode entities before you match, and remember a scraped page may be double-encoded.
Retina images and package specifiers look exactly like addresses. logo@2x.png and lodash@4.17.21 match any reasonable email pattern, because they are a token, an @, and a dotted token. Strip <script>, <style> and <code> subtrees first, and reject a hit whose final domain label has no letter in it.
Hidden addresses are usually traps. An address inside display:none, or in white text on white, is often there to catch harvesters. Extract from rendered text with styles applied and you skip most of them; extract from raw HTML and you collect them all, along with the reputation that comes from mailing them.
A mailto: href beats the page text, because it gives you an unambiguous address, already delimited. Take those first. What no pattern will solve is legal [at] example [dot] com, addresses assembled by JavaScript, and addresses drawn into an image: three separate problems, none of them a character class.
At a thousand pages you eyeball the output and fix what you see. At a million the arithmetic changes: a two-percent false-positive rate is twenty thousand junk rows, and a bounce rate that high can damage a sending domain. Normalise before you deduplicate, or the same mailbox arrives three times in three cases; we went through the ways that word gets used in data normalization. Sites that fight extraction with rendered-only markup, per-session obfuscation and honeypot fields are where a managed extraction service earns its keep, because the cost sits in maintenance rather than the first script.
And an email address identifies a person. Under European rules it is personal data whether or not it sat on a public page, a legal question rather than a technical one, covered in GDPR and web scraping. A regex finding an address is not a lawful basis for anything.
After the Regex: MX, Null MX, and a Confirmation
Once the string is shaped correctly, the next cheap check is DNS. Under RFC 5321 an absent MX record falls back to A or AAAA, so a domain with a web server and no mail server looks deliverable to a naive check.
RFC 7505 fixed that in 2015 with the "null MX". It is a single MX record of preference 0 pointing at ., which is not a valid hostname and so cannot be confused with a real one. Its abstract puts it exactly: the record "formalizes the existing mechanism by which a domain announces that it accepts no mail, without having to provide a mail server". Handle it, because a null MX is a definitive no that arrives before you have sent anything. Email::Valid 1.204 lists a null-MX detection fix in its release notes, which shows how recently libraries were getting it wrong.
Past DNS, certainty gets expensive. Probing with RCPT TO is unreliable against catch-all domains and greylisting, and it is the behaviour that gets sending IPs blocked. What remains is what perlfaq9 recommended two decades ago and what .NET's four-comparison validator concedes: send a message and see whether anyone acts on it.
Recommendation
For almost every application, use the HTML5 expression. It is what browsers already enforce, it is short enough to read, three independent implementations agree on it, and it rejects the mistakes that matter: a missing @, stray whitespace, a malformed domain label.
Know what it does not do. It accepts nathan@localhost, because the domain dot is optional; the earlier version of this article claimed otherwise and was wrong. It accepts john..doe@example.com, which the grammar forbids. It rejects every internationalised local part. And it says nothing about length, so add the two checks yourself: 64 octets before the @, 254 for the whole address.
If your language ships a parser, prefer it. Go's net/mail, Python's email-validator, PHP's egulias/email-validator and Perl's Email::Valid give better answers than any regex, with MX checking and normalisation in the same call.
Whatever you pick, anchor it, cap the input length, and treat the result as a format check. The only proof that an address works is a message that arrives and a human who acts on it.