validator-syntax
The presets, options, checks, results, limits, and fixtures of @email-utils/validator-syntax.
@email-utils/validator-syntax answers "is the address well-formed?" It
splits an address into its local part, domain, TLD, and comments under one of
four presets, or returns the first check it fails. It's synchronous and runs
in the browser and in Node.
npm install @email-utils/validator-syntax@nextUntil 1.0.0, the v1 release candidates are on the next tag. Every export's
signature is in the API reference.
import {
,
,
} from '@email-utils/validator-syntax';
('ada.lovelace@example.co.uk');
// => { ok: true, value: { local: 'ada.lovelace', domain: 'example.co.uk', tld: 'uk', comments: [] } }
const = ({ : 'rfc5321' });
.('ada lovelace@example.com');
// => { ok: false, reason: 'syntax.local.unquoted_space', index: 3 }
.('"ada lovelace"@example.com'); // => trueFailed results also carry a message; the samples on this page leave it out.
Presets
A preset is the base rule set. Pick one with the preset option and adjust
it with the other options.
practical(the default): what real mailboxes look like. It rejects forms that are legal but never seen on real mailboxes: quoted local parts, the%and!route characters, comments, and domain literals. The TLD must be in the IANA set.rfc5321: what SMTP accepts on the wire, quoted local parts and IP address literals included.rfc5322: RFC 5322's full addr-spec, with comments, folding whitespace, and the obsolete syntax RFC 5322 requires parsers to accept.html5: the WHATWGinput[type=email]regex, for parity with browser forms, plus the 254-character address cap andmaxLength.
What each preset accepts with its default options:
| Rule | practical | rfc5321 | rfc5322 | html5 |
|---|---|---|---|---|
| Local-part characters | atext without % and ! | atext | atext | atext |
| Dots in the local part | Single, between characters | Single, between characters | Single, between words | Anywhere, any number |
| Quoted strings | No | The whole local part only | Any word, mixed with atoms | No |
| Domain characters | Letters, digits, - | Letters, digits, - | atext | Letters, digits, - |
| Domain literals | No | IPv4 and IPv6: address literals | Any text RFC 5322 allows in brackets | No |
| Comments | No | No | Around every word and label | No |
| Obsolete syntax | No | No | Yes | No |
| Local-part cap | 64 | 64 | None | None |
| Domain cap | 253 | 253 | 253 | None |
| Label cap | 63 | 63 | 63 | 63 |
| Address cap | 254 | 254 | 254 | 254 |
| TLD in the IANA set | Required | Not checked | Not checked | Not checked |
| Dotless domains | No | No | No | Yes |
maxLength | 512 | 512 | 512 | 512 |
atext is RFC 5322's set: letters, digits, and !#$%&'*+-/=?^_`{|}~. In
every preset, a domain label can't start or end with a hyphen, and
xn-- A-labels are ordinary labels.
practical
A dot-atom local part: letters, digits, and #$&'*+-/=?^_`{|}~, with single
dots between them. No quoted strings, no % or !, no comments or
whitespace. Hostname labels with at least one dot, no domain literals, and
a TLD from the IANA set. allowComments, allowIpLiteral, allowUnicode,
and allowIdn can each turn on what their names say.
rfc5321
An RFC 5321 Dot-string or Quoted-string local part, never both mixed, capped
at 64 characters: "ada lovelace"@example.com passes, ada."lovelace"@example.com
doesn't. Escapes in a quoted string take printable ASCII and space. Hostname
labels with at least one dot, or an address literal: IPv4 ([192.0.2.1]) or
IPv6 ([IPv6:2001:db8::1], the tag in any case), with no more than six
groups beside a ::. Anything else in brackets fails as
syntax.domain.literal_invalid. No comments or whitespace.
rfc5322
RFC 5322's addr-spec with its obsolete syntax:
- dot-separated atoms and quoted strings mixed, like
just."quoted".atoms@example.com; - comments and folding whitespace around every word and label, like
test . test@example.comandtest.(comment)test@example.com, with a CRLF only when a space or tab follows it; - control characters other than NUL in quoted strings, comments, and domain literals, and any ASCII character after a backslash;
- any text RFC 5322 allows in a domain literal, like
[RFC-5322-literal]; - any atext in a domain label, like
james@cb$.com, since RFC 5322's domain is a dot-atom. The hostname rules for hyphens and the 63-character label cap still apply.
There's no 64-character cap on the local part. A dotted domain is still
required unless allowNoTld is on. allowComments: false turns comments
off but keeps the rest of the obsolete syntax, folding whitespace included.
html5
The WHATWG input[type=email] regex: atext and dots anywhere in the local
part (john..doe@example.com, .ada@example.com), hostname labels of up
to 63 characters, and dotless domains (admin@mailserver1). No local-part or
domain cap, but the address can't pass 254 characters, which a browser's
regex doesn't check. Its grammar is ASCII with no comments or literals, so
allowComments, allowUnicode, allowIdn, and allowIpLiteral can't be
true with it.
Options
SyntaxOptions is a flat object:
a preset and overrides on top of it. Every option also takes undefined,
which means unset.
| Option | Type | Default | What it does |
|---|---|---|---|
preset | Preset | 'practical' | The base rule set: 'practical', 'rfc5321', 'rfc5322', or 'html5'. See Presets. |
checkTld | boolean | true in practical, else false | Checks the TLD against the bundled IANA set, ignoring case. A dotless domain or a domain literal has no TLD, so it isn't checked. |
allowNoTld | boolean | true in html5, else false | Accepts a domain with no dot, like localhost. |
allowComments | boolean | true in rfc5322, else false | Accepts RFC 5322 comments, like ada(work)@example.com. true throws with rfc5321 and html5. |
allowUnicode | boolean | false | Accepts non-ASCII in the local part (RFC 6531). true throws with html5. See International addresses. |
allowIdn | boolean | false | Accepts domain labels written as U-labels, like bücher. true throws with html5. See International addresses. |
allowIpLiteral | boolean | true in rfc5321 and rfc5322, else false | Accepts a domain literal. true throws with html5. |
maxLength | number | 512 | The longest input accepted, in UTF-16 code units. A positive integer, or Infinity for no limit. See maxLength. |
Only true throws for an override the preset can't hold; false is always
accepted. parseAddress and isValidSyntax check the options on every call;
createSyntaxValidator
checks them once and returns a frozen validator. Malformed options throw
TypeError:
- options that aren't an object (
nullincluded); - a key that isn't one of the eight above;
- an unknown preset;
- an override that isn't a boolean;
- a
maxLengththat isn't a positive integer orInfinity; truefor an override the preset can't hold.
Input that isn't a string also throws TypeError. Everything else returns a
result.
Comments
With allowComments: true, practical accepts comments only at the ends of
the local part and the domain, where RFC 5322's dot-atom allows them:
(work)ada@example.com and ada@example.com(home) pass, ada.(work)lovelace@example.com
fails as syntax.comment.not_allowed. rfc5322 accepts them around every
word and label. Comments can nest and hold escapes and folding whitespace.
They're lifted out of the address into
ParsedAddress.comments.
maxLength
maxLength bounds the input as written: email.length, comments and folding
whitespace included. Longer input fails as syntax.address.too_long before
anything is scanned, so oversized input is rejected in constant time however
long it is. The default, 512, is twice the address cap, which leaves room for
comments and folding whitespace. The address caps still apply
within it.
A validator from createSyntaxValidator exposes the limit it applies as
maxLength: 512 by default,
Infinity when there's none. Code that trims or otherwise pre-processes input
before parsing it can check the raw length against it first, so padding can't
carry input past the limit.
import { } from '@email-utils/validator-syntax';
const = ();
.; // => 512
const = `${' '.(600)}ada@example.com`;
. > .; // => trueInternational addresses
allowUnicode and allowIdn are off in every preset and can be turned on
in all but html5.
allowUnicodeaccepts any non-ASCII character in local-part atoms and quoted strings, like用户@example.com, and in comments where comments are allowed. A lone surrogate, which no UTF-8 text can hold, fails assyntax.local.invalid_char. The local part then counts in UTF-8 octets, as RFC 6531 keeps the caps: 21 three-byte characters fit in 64 octets, 22 don't. It doesn't touch the domain; that'sallowIdn.allowIdnaccepts domain labels written as U-labels, likeada@bücher.de. Each one must convert to an A-label (xn--bcher-kva) under UTS #46, as the WHATWG URL parser applies it with its bidi and joiner rules, or it fails assyntax.domain.label_invalid. So does a label UTS #46 maps to plain ASCII (fullwidthexample) or splits in two (。). Each label is judged on its own, so the bidi rule doesn't reach across labels.- The 63 and 253 caps apply to the A-label form. A U-label of more than 63
code points fails without being converted, since its A-label can't be
shorter. Once the A-labels take the domain past 253, the rest aren't
converted, and the domain fails as
syntax.domain.too_longunless something else in the domain fails. ParsedAddress.domainandtldkeep the labels as written; nothing is converted in the result. The TLD check knows IDN TLDs in both forms:practicalacceptsada@пример.рфwithallowIdn, andada@xn--e1afmkfd.xn--p1aiwithout it.- A-labels are hostname labels, so every preset accepts them without
allowIdn. They're checked as hostname labels only; the Punycode isn't decoded.
Domain literals
With literals on, rfc5321 and practical (with allowIpLiteral: true)
accept IPv4 and IPv6: address literals only; rfc5322 also accepts any
other text its grammar allows in brackets. A literal must be the whole
domain. With literals off, a [ fails as syntax.domain.invalid_char; with
them on, a literal that's malformed or never closes fails as
syntax.domain.literal_invalid. A domain literal has no TLD and needs no
dot.
What's checked
The first failure wins, in this order:
- Empty input fails as
syntax.address.empty. - Input longer than
maxLengthfails assyntax.address.too_long. - The address splits on the last
@outside a quoted string, comment, or domain literal. With none, it fails assyntax.address.no_at, or assyntax.comment.unterminatedat the(when a comment that never closes took the@with it. - The local part, left to right, then the local-part cap.
- The domain, left to right, then the domain cap.
- The address cap.
- A dotless domain, unless the preset or
allowNoTldallows it. - The TLD, when
checkTldis on.
A quoted string opens only at the start of a word; anywhere else a " is an
invalid character. A quoted string, comment, or domain literal left open runs
to the end of the input, taking any @ with it.
Whitespace in the local part outside a quoted string fails as
syntax.local.unquoted_space in every preset: a space, a tab, or a folded
line (a CRLF and a space or tab). rfc5322 allows folding whitespace between
tokens, so there it fails only between two words with no dot between them. A
lone CR or LF is syntax.local.invalid_char.
Where a failure has a position, index is the UTF-16 offset of the first
offending character in the whole input: the ( of a disallowed or
unterminated comment, the [ of a bad domain literal, the start of an
overlong or unconvertible label, the second of two dots, the first whitespace
outside quotes, and the CR of a CRLF with no space or tab after it. Empty
input, an empty local part or domain, maxLength, a missing @, the caps,
a dotless domain, and the TLD have no position. Every code and what it means is in the
reason-code catalogue.
Results
parseAddress returns a
Result:
type Result<T> =
| { ok: true; value: T }
| { ok: false; reason: ReasonCode; message?: string; index?: number };Branch on reason. message is for people and may change in any release.
isValidSyntax and a validator's
isValid return ok alone.
ParsedAddress
ParsedAddress is the address as
written, split and with comments and folding whitespace lifted out. The
parser doesn't normalize anything.
| Field | Type | Holds |
|---|---|---|
local | string | The local part, case as written. A quoted local part keeps its quotes and escapes; folded lines lose their CRLF but keep the space or tab. |
domain | string | The domain, case as written. U-labels stay U-labels; a domain literal keeps its brackets. |
tld | string (optional) | The last label, case as written. Absent for a domain literal or a dotless domain. |
comments | AddressComment[] | Comments in input order; empty when there are none. |
Each AddressComment has the
comment's text, as written between the outer parentheses (nested comments,
escapes, and folding whitespace included), and its position:
before-localandafter-local: at the start or end of the local part;before-domainandafter-domain: at the start or end of the domain;inside-localandinside-domain: between the words of the local part or the labels of the domain, which onlyrfc5322allows.
import { } from '@email-utils/validator-syntax';
('"Ada Lovelace"@Example.COM', { : 'rfc5321' });
// => { ok: true, value: { local: '"Ada Lovelace"', domain: 'Example.COM', tld: 'COM', comments: [] } }
('ada(work)@example.com(home)', { : 'rfc5322' });
// => {
// ok: true,
// value: {
// local: 'ada',
// domain: 'example.com',
// tld: 'com',
// comments: [
// { text: 'work', position: 'after-local' },
// { text: 'home', position: 'after-domain' },
// ],
// },
// }Limits
| Limit | Value | Presets | Counted in |
|---|---|---|---|
maxLength | 512 | All | UTF-16 code units of the input as written |
| Address | 254 | All | The local part, @, and the domain, each counted as below |
| Local part | 64 | practical, rfc5321 | Characters; UTF-8 octets with allowUnicode |
| Domain | 253 | practical, rfc5321, rfc5322 | Characters, with U-labels as A-labels |
| Domain label | 63 | All | Characters, with a U-label as its A-label |
The caps count the address without comments or folding whitespace; only
maxLength counts those. A failed cap has no index.
Fixtures
@email-utils/validator-syntax/fixtures publishes the corpus the package is
tested against, so dependents can check they split and judge addresses the
same way. Its exports are documented in the
fixtures API reference.
syntaxFixtures: every fixture from every source, each address once.legacyFixtures: every address the 0.0.1 test suite checked. Each addslegacy, what 0.0.1 returned, andflipped, whypracticalnow disagrees, where it does.isemailFixtures: Dominic Sayers' is_email test set (BSD-3-Clause, credited in the package'sTHIRD_PARTY_NOTICES.md). Each addsisemail, the test's id.wikipediaFixtures: the examples from Wikipedia's "Email address" article.rfc3696Fixtures: RFC 3696's examples, as corrected by its erratum 246.presets: the four preset names, in column order.syntaxFeatures: the features the support matrix has a row for, with their labels.
Each fixture has the address, a description, and expected: the result
under each preset's default options, as { ok: true } or { ok: false, reason, index? },
without the value or message. A fixture that's a well-formed example of a
feature also has feature. The set may grow in any minor release.
supportMatrix() returns
one row per feature, with each preset's support: 'yes' when it accepts every
fixture tagged with that feature, 'no' when it accepts none, and
'partial' in between. Each row also carries its fixtures, for examples.
previewSyntaxOptions(options?, addresses?)
shows what a configuration does. It runs the addresses, or syntaxFixtures
by default, through createSyntaxValidator(options) and splits them into
valid and invalid, each in input order. Invalid entries carry the
reason, message, and index. changed marks the addresses the overrides
move, the ones the preset alone would judge the other way; with no
overrides, nothing is changed. description comes from the corpus and is
absent for addresses you pass in. Malformed options, or addresses that
isn't an array of strings, throw TypeError before anything is parsed.
import { } from '@email-utils/validator-syntax';
import {
,
,
} from '@email-utils/validator-syntax/fixtures';
.(
({ , }) =>
(, { : 'html5' }) === ..,
); // => true
({ : 'html5' }, [
'ada@localhost',
'ada@example..com',
]);
// => {
// valid: [{ address: 'ada@localhost', changed: false }],
// invalid: [
// { address: 'ada@example..com', reason: 'syntax.domain.label_invalid', index: 12, changed: false },
// ],
// }