validation.email
Is this a plausible email address? A documented, pragmatic subset of RFC 5322, not the full grammar.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 28 tests, run in TypeScript, Python and Rust.
What it does
THE ONLY TRUE VALIDATION OF AN EMAIL ADDRESS IS SENDING MAIL TO IT and seeing the recipient act on the message. Syntax cannot tell you that a mailbox exists, that it is still in use, or that it belongs to the person typing it. Use this to catch typos while someone is still at the keyboard, then confirm by email. Do not use it to decide an address is real, and do not use it to reject an address a user insists is theirs without offering a way through.
This is a deliberate subset of RFC 5322, not an implementation of it. Nobody should claim to implement RFC 5322: the real grammar admits comments, folded whitespace, quoted strings containing spaces and bracketed IP literals, and almost nothing downstream of a signup form can handle them. Accepting them here would only let addresses through that the mail stack later rejects.
For example
isEmail(alice@example.com)→ true a plain addressisEmail(first.last@mail.example-corp.co.uk)→ true a subdomain and a hyphen in the domainisEmail(user+tag_01!#$%&/=?^{|}~@example.org)→ true plus addressing and the other atext specials
The function
The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.
export function isEmail(value: string): boolean
| value | string | The candidate address, exactly as typed; no trimming is applied |
| returns | bool |
Your code names it in one line, in the file that uses it
import { isEmail } from "#fune/validation.email@^1";
// Every character class is hand-rolled rather than expressed as a regular
// expression. The Rust sibling has no regex crate available, and the only way
// to be sure three implementations agree on an edge case is for all three to
// make the same decision in the same place.
/** RFC 5321 caps the local part at 64 octets. */
const MAX_LOCAL = 64;
/** RFC 5321 caps a forward path at 256 octets including the angle brackets. */
const MAX_TOTAL = 254;
/** RFC 1035 caps a DNS label at 63 octets. */
const MAX_LABEL = 63;
/** The "atext" specials from RFC 5322, plus the dot handled separately below. */
const LOCAL_SPECIALS = "!#$%&'*+-/=?^_`{|}~";
function isDigit(ch: string): boolean {
return ch >= "0" && ch <= "9";
}
function isLetter(ch: string): boolean {
return (ch >= "a" && ch <= "z") || (ch >= "A" && ch <= "Z");
}
function isLetterOrDigit(ch: string): boolean {
return isLetter(ch) || isDigit(ch);
}
function isLocalChar(ch: string): boolean {
return isLetterOrDigit(ch) || LOCAL_SPECIALS.indexOf(ch) >= 0;
}
/**
* Is this a plausible email address?
*
* This is a deliberate, documented subset of RFC 5322, not an implementation
* of it. The full grammar admits comments, folded whitespace, quoted strings
* with embedded spaces and bracketed IP literals; almost nothing downstream
* of a signup form can handle those, and accepting them would let addresses
* through that the mail stack then rejects.
*
* The only true validation of an email address is sending mail to it and
* seeing the recipient act on it. Use this to catch typos at the keyboard,
* then confirm by email. Never use it to decide that an address is real.
*/
export function isEmail(value: string): boolean {
if (typeof value !== "string") return false;
if (value.length === 0 || value.length > MAX_TOTAL) return false;
// Exactly one @: the last-@ split used by lenient parsers quietly accepts
// "a@b@c", which no MTA will route.
let at = -1;
for (let i = 0; i < value.length; i++) {
if (value[i] === "@") {
if (at !== -1) return false;
at = i;
}
}
if (at <= 0 || at === value.length - 1) return false;
return isLocalPart(value.slice(0, at)) && isDomain(value.slice(at + 1));
}
function isLocalPart(local: string): boolean {
if (local.length === 0 || local.length > MAX_LOCAL) return false;
// A dot is a separator between atoms, so it cannot lead, trail or double up.
if (local[0] === "." || local[local.length - 1] === ".") return false;
for (let i = 0; i < local.length; i++) {
const ch = local[i];
if (ch === ".") {
if (local[i - 1] === ".") return false;
continue;
}
if (!isLocalChar(ch)) return false;
}
return true;
}
function isDomain(domain: string): boolean {
// MAX_TOTAL already bounds this, but stating the domain limit separately
// keeps the rule readable and survives any future change to the total.
if (domain.length === 0 || domain.length > MAX_TOTAL - 2) return false;
const labels = domain.split(".");
// At least one dot. A bare "localhost" is a valid host but not an address
// anyone outside that machine can deliver to, and a signup form is asking
// for the latter.
if (labels.length < 2) return false;
for (const label of labels) {
if (label.length === 0 || label.length > MAX_LABEL) return false;
if (label[0] === "-" || label[label.length - 1] === "-") return false;
for (let i = 0; i < label.length; i++) {
const ch = label[i];
if (!isLetterOrDigit(ch) && ch !== "-") return false;
}
}
// The top-level label must be two or more letters. This is what rejects
// "user@example.123" and the bracketed-IP form, and it is the rule most
// likely to need revisiting: it also rejects punycode-free internationalised
// TLDs written in their native script.
const tld = labels[labels.length - 1];
if (tld.length < 2) return false;
for (let i = 0; i < tld.length; i++) {
if (!isLetter(tld[i])) return false;
}
return true;
}
/**
* The domain half of an address, lowercased, or null if the address is not
* one this capability accepts.
*
* Domains are case-insensitive; local parts are not, so this deliberately
* only normalises the half where doing so is safe.
*/
export function emailDomain(value: string): string | null {
if (!isEmail(value)) return null;
const domain = value.slice(value.indexOf("@") + 1);
let out = "";
for (let i = 0; i < domain.length; i++) {
const ch = domain[i];
out += ch >= "A" && ch <= "Z" ? String.fromCharCode(ch.charCodeAt(0) + 32) : ch;
}
return out;
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the TypeScript package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:
fune add validation.email
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./validation.email-1.0.0-typescript.fune, or fetch it from a terminal with fune pull validation.email@1.0.0:typescript.
The whole function, every language, is one file too: validation.email-1.0.0.fune, 22,673 bytes, sha256 261bd3391546f0c3952214872b5e7e71f138c040d13438502b1ba9a933196a4b. It installs into a project of any language.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
// fune: before validation.email
after — your function gets the result and the arguments, and returns the final result.
// fune: after validation.email
replace — it requires no other capability, so there is no dependency to replace.
step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show validation.email --steps.
// fune: step validation.email after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
| Case | Arguments | Expected | |
|---|---|---|---|
| a plain address | alice@example.com | → | true |
| a subdomain and a hyphen in the domain | first.last@mail.example-corp.co.uk | → | true |
| plus addressing and the other atext specials | user+tag_01!#$%&/=?^{|}~@example.org | → | true |
| case is preserved and irrelevant to acceptance | Alice.Smith@Example.COM | → | true |
| the shortest address this accepts | a@b.co | → | true |
| the empty string is not an address | → | false | |
| no at sign at all | alice.example.com | → | false |
| two at signs, which no MTA will route | alice@example@com | → | false |
| a space inside the local part | alice smith@example.com | → | false |
| a leading space is not trimmed | alice@example.com | → | false |
Show the other 18 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a domain with no dot is not deliverable from outside | alice@localhost | → | false |
| consecutive dots in the local part | alice..smith@example.com | → | false |
| a leading dot in the local part | .alice@example.com | → | false |
| a trailing dot on the domain leaves an empty label | alice@example.com. | → | false |
| a hyphen may not start a domain label | alice@-example.com | → | false |
| an all-numeric top level domain is refused | alice@example.123 | → | false |
| a bracketed IP literal is out of scope | alice@[192.168.0.1] | → | false |
| a quoted local part is out of scope | "alice smith"@example.com | → | false |
| a single letter top level domain is refused | alice@example.c | → | false |
| a non-ASCII local part is out of scope | alicé@example.com | → | false |
| an empty domain | alice@ | → | false |
| an empty local part | @example.com | → | false |
| sixty-four characters is the longest local part | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa@example.com | → | true |
| sixty-five characters is one too many | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa@example.com | → | false |
| a domain label may be sixty-three characters but not more | alice@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com | → | false |
| two hundred and fifty-four characters is the longest address | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa… | → | true |
| two hundred and fifty-five characters is one too many | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa… | → | false |
| a non-string argument is not an address | 42 | → | false |
More from the author
ACCEPTED: exactly one @; a local part of 1-64 characters (RFC 5321) made of ASCII letters, digits and the atext specials ! # $ % & ' * + - / = ? ^ _ ` { | } ~ plus interior dots; a domain of two or more dot-separated labels, each 1-63 characters (RFC 1035) of ASCII letters, digits and interior hyphens; a top-level label of two or more letters; a total length of at most 254 characters (RFC 5321 allows 256 octets for a forward path including the angle brackets).
REJECTED, on purpose: quoted local parts ("alice smith"@example.com); comments and folded whitespace; bracketed IP literals (alice@[192.168.0.1]); bare hostnames with no dot (alice@localhost) - valid mail locally, undeliverable from anywhere else; all-numeric or single-letter top-level domains; leading, trailing or doubled dots in the local part; any non-ASCII character, so internationalised addresses must be punycoded before they reach this function; any leading or trailing whitespace, because trimming is the caller's decision and silently accepting " a@b.co" hides a paste bug.
Case is preserved and never significant to the answer. Local parts are technically case-sensitive, so this capability does not lowercase anything; emailDomain / email_domain lowercases only the domain half, where doing so is safe.
No regular expressions are used, in any of the three languages. Rust has no regex crate available here, and having all three walk the same character classes in the same order is what makes the shared vectors meaningful rather than coincidental.
Validators answer rather than throw: an unparseable value is not an exceptional condition, it is the answer "no". A non-string argument is therefore false, not a TypeError.
Files
| Path | Bytes |
|---|---|
| README.md | 2,558 |
| impl/python.py | 4,410 |
| impl/rust.rs | 5,412 |
| impl/typescript.ts | 4,598 |
| vectors.json | 3,553 |