Input type email validation
Put a list of addresses into the field, read validity.typeMismatch for each one, then send the same list to your own route and compare the two verdicts. Chrome accepted a@b and a 320 character address here, and refused a bracketed IP literal. Seven addresses out of twelve got opposite answers.
Why check this
Run this before sign-off on any form that collects an address, and again after a validation library is added or upgraded. Three judges rule on the same strings: the browser, your route, and the field itself.
The failure it prevents is a stored address no mail system can use. a@b satisfies type="email", and a route that trusts the attribute keeps it. The ticket arrives days later, about a confirmation mail that never came.
Syntax is not deliverability. Nothing here proves a mailbox exists. No step sends mail, and none asks a mail server or a DNS record anything, so an address that passes every column below can still bounce.
Prerequisites
- Node 22 and curl. Everything runs on
127.0.0.1:8932. - Chrome, plus
puppeteer-core(npm i puppeteer-core), withCHROMEset to your own binary. This run drove Chrome 152.0.7977.76, and a browser figure is one capture on one machine. - The launch line carries
--lang=en-US, becausevalidationMessagefollows the browser UI locale. Without that flag this machine returns the step 1 messages in Ukrainian. - The email state of the input element in the HTML standard, which defines the grammar and the value sanitizing.
- The non-ASCII fixture is written as
\uescapes, so nothing normalises it in transit.
target.mjs serves the form and judges one address with the regular expression most often copied into a backend, plus a length cap.
import { createServer } from 'node:http';
const PORT = 8932;
const RULE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const MAX = 254;
const FORM = `<!doctype html><html><head><meta charset="utf-8"><title>email fixture</title></head>
<body>
<form id="f" method="post" action="/submit">
<label for="one">Email</label>
<input id="one" name="one" type="email">
<label for="many">Recipients</label>
<input id="many" name="many" type="email" multiple>
<button id="go" type="submit">Send</button>
</form>
</body></html>`;
function verdict(a) {
if (a.length > MAX) return { ok: false, reason: 'over ' + MAX + ' characters' };
if (!RULE.test(a)) return { ok: false, reason: 'does not match the address rule' };
return { ok: true, reason: '' };
}
createServer((req, res) => {
const url = new URL(req.url, 'http://127.0.0.1:' + PORT);
if (req.method === 'GET' && url.pathname === '/') {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
return res.end(FORM);
}
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const body = new URLSearchParams(Buffer.concat(chunks).toString('utf8'));
if (url.pathname === '/judge') {
const a = body.get('address') ?? '';
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
return res.end(JSON.stringify({ chars: a.length, ...verdict(a) }) + '\n');
}
if (url.pathname === '/submit') {
const out = [];
for (const name of ['one', 'many']) {
const v = body.get(name) ?? '';
out.push(name + ' chars ' + v.length + ' server ' + (verdict(v).ok ? 'accepts' : 'rejects')
+ ' received "' + v + '"');
}
res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' });
return res.end(out.join('\n') + '\n');
}
res.writeHead(404, { 'content-type': 'text/plain' }).end('not found\n');
});
}).listen(PORT, '127.0.0.1', () => console.log('email fixture on http://127.0.0.1:' + PORT));
probe.mjs drives Chrome against that form and asks the route for its verdict.
import { launch } from 'puppeteer-core';
const CHROME = process.env.CHROME || 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const ORIGIN = 'http://127.0.0.1:8932';
const pad = (n, w) => String(n).padStart(w);
const cps = (s) => [...s].map((c) => 'U+' + c.codePointAt(0).toString(16).toUpperCase().padStart(4, '0')).join(' ');
const ADDRESSES = [
['qa@example.com', 'qa@example.com'],
['a@b', 'a@b'],
['user+tag@example.com', 'user+tag@example.com'],
['user@localhost', 'user@localhost'],
['user@example.com.', 'user@example.com.'],
['user@example..com', 'user@example..com'],
['"quoted string"@example.com', '"quoted string"@example.com'],
['user@[127.0.0.1]', 'user@[127.0.0.1]'],
['\u00E9l\u00E8ve@example.com', '\u00E9l\u00E8ve@example.com'],
['308 x a, then @example.com', 'a'.repeat(308) + '@example.com'],
['first last@example.com', 'first last@example.com'],
['Jane Doe <jane@example.com>', 'Jane Doe <jane@example.com>'],
];
const browser = await launch({ executablePath: CHROME, headless: true, args: ['--lang=en-US'] });
const page = (await browser.pages())[0];
await page.goto(ORIGIN + '/', { waitUntil: 'networkidle2' });
const put = (id, v) => page.evaluate((i, t) => {
const el = document.getElementById(i);
el.value = t;
return { value: el.value, chars: el.value.length, typeMismatch: el.validity.typeMismatch, message: el.validationMessage };
}, id, v);
const ask = async (v) => (await (await fetch(ORIGIN + '/judge', {
method: 'POST', body: new URLSearchParams({ address: v }),
})).json());
const mode = process.argv[2];
if (mode === 'one') {
for (const v of ['a@b', 'user@example.com.', 'first last@example.com']) {
const s = await put('one', v);
console.log('value "' + s.value + '"');
console.log(' typeMismatch ' + s.typeMismatch + ' message "' + s.message + '"');
}
} else if (mode === 'judge') {
for (const [label, v] of ADDRESSES) {
const b = await put('one', v);
const s = await ask(v);
console.log(label.padEnd(28) + ' chars ' + pad(v.length, 3)
+ ' browser ' + (b.typeMismatch ? 'typeMismatch' : 'valid ')
+ ' server ' + (s.ok ? 'accepts' : 'rejects')
+ (b.typeMismatch !== !s.ok ? ' <- disagree' : ''));
}
console.log('code points of row 9 local part: ' + cps('\u00E9l\u00E8ve'));
} else if (mode === 'typed') {
const typed = ' qa@example.com ';
await page.click('#one');
await page.type('#one', typed);
const s = await page.evaluate(() => {
const el = document.getElementById('one');
return { value: el.value, chars: el.value.length, typeMismatch: el.validity.typeMismatch };
});
console.log('typed chars ' + typed.length + ' "' + typed + '"');
console.log('el.value chars ' + s.chars + ' "' + s.value + '" typeMismatch ' + s.typeMismatch);
await Promise.all([page.waitForNavigation({ waitUntil: 'networkidle2' }), page.click('#go')]);
console.log(await page.evaluate(() => document.body.textContent.trim()));
} else if (mode === 'multiple') {
let differ = 0;
for (const [, v] of ADDRESSES) {
const a = await put('one', v);
const b = await put('many', v);
if (a.typeMismatch !== b.typeMismatch) differ += 1;
}
console.log('of ' + ADDRESSES.length + ' single addresses, ' + differ + ' get a different verdict in the multiple field');
for (const v of ['a@b.com,c@d.com', ' a@b.com , c@d.com ', 'a@b.com, ', 'a@b.com,', '', 'a@b.com,first last@x.com']) {
const a = await put('one', v);
const b = await put('many', v);
console.log('input "' + v + '"');
console.log(' one ' + (a.typeMismatch ? 'typeMismatch' : 'valid ') + ' kept "' + a.value + '"');
console.log(' many ' + (b.typeMismatch ? 'typeMismatch' : 'valid ') + ' kept "' + b.value + '"');
}
}
await browser.close();
Start the target and leave it running.
node target.mjs
Steps
- Step 1.
Read the verdict and the message the browser would show, for three addresses.
node probe.mjs onevalue "a@b" typeMismatch false message "" value "user@example.com." typeMismatch true message "'.' is used at a wrong position in 'example.com.'." value "first last@example.com" typeMismatch true message "A part followed by '@' should not contain the symbol ' '."a@bis valid. The grammar wants one@with something on each side and no dot, so no mail system is narrower. The other rows carry their reason invalidationMessage, the text the person filling the form sees. - Step 2.
Send the whole list past the browser and the route in one pass.
node probe.mjs judgeqa@example.com chars 14 browser valid server accepts a@b chars 3 browser valid server rejects <- disagree user+tag@example.com chars 20 browser valid server accepts user@localhost chars 14 browser valid server rejects <- disagree user@example.com. chars 17 browser typeMismatch server accepts <- disagree user@example..com chars 17 browser typeMismatch server accepts <- disagree "quoted string"@example.com chars 27 browser typeMismatch server rejects user@[127.0.0.1] chars 16 browser typeMismatch server accepts <- disagree élève@example.com chars 17 browser typeMismatch server accepts <- disagree 308 x a, then @example.com chars 320 browser valid server rejects <- disagree first last@example.com chars 22 browser typeMismatch server rejects Jane Doe <jane@example.com> chars 27 browser typeMismatch server rejects code points of row 9 local part: U+00E9 U+006C U+00E8 U+0076 U+0065Seven rows of twelve disagree, in both directions. The browser is wider on
a@b, onuser@localhostand on the 320 character address. The route is wider on a trailing dot, a doubled dot, a bracketed IP literal and a local part spelled with U+00E9 and U+00E8. Neither column is the right answer: a person meets one rule and the stored data meets another. - Step 3.
Ask the route on its own, with no browser in the path.
for a in 'a@b' 'user@example.com.' 'user@[127.0.0.1]' 'first last@example.com'; do curl -s -X POST http://127.0.0.1:8932/judge --data-urlencode "address=$a"; done{"chars":3,"ok":false,"reason":"does not match the address rule"} {"chars":17,"ok":true,"reason":""} {"chars":16,"ok":true,"reason":""} {"chars":22,"ok":false,"reason":"does not match the address rule"}The two middle rows are addresses Chrome refuses to submit, so only a request made outside the form tests this rule.
- Step 4.
Send one address longer than any mailbox can be.
curl -s -X POST http://127.0.0.1:8932/judge --data-urlencode "address=$(printf 'a%.0s' $(seq 308))@example.com"{"chars":320,"ok":false,"reason":"over 254 characters"}The route stops at 254 characters. The browser applied no length limit: the same string reads as valid in step 2.
- Step 5.
Type an address with padding around it, then submit the form.
node probe.mjs typedtyped chars 18 " qa@example.com " el.value chars 14 "qa@example.com" typeMismatch false one chars 14 server accepts received "qa@example.com" many chars 0 server rejects received ""Eighteen characters typed, fourteen held. The field strips leading and trailing whitespace before the value is read, so a padded string sent through this form never reaches the route padded. The
manyline is the empty second field. - Step 6.
Put the same strings into a field that carries
multiple.node probe.mjs multipleof 12 single addresses, 0 get a different verdict in the multiple field input "a@b.com,c@d.com" one typeMismatch kept "a@b.com,c@d.com" many valid kept "a@b.com,c@d.com" input " a@b.com , c@d.com " one typeMismatch kept "a@b.com , c@d.com" many valid kept "a@b.com,c@d.com" input "a@b.com, " one typeMismatch kept "a@b.com," many typeMismatch kept "a@b.com," input "a@b.com," one typeMismatch kept "a@b.com," many typeMismatch kept "a@b.com," input "" one valid kept "" many valid kept "" input "a@b.com,first last@x.com" one typeMismatch kept "a@b.com,first last@x.com" many typeMismatch kept "a@b.com,first last@x.com"The per-address rule does not move: all twelve keep their verdict. The grammar around them changes. A comma separated pair is refused alone and valid with
multiple, and the second input rewrites its own value, dropping the spaces around the comma that the single field keeps.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| typeMismatch is false and the route rejects | The browser's grammar is wider than your rule | Decide which rule belongs to the product, then move the other one to match it. |
| typeMismatch is true and the route accepts | The form cannot produce a value the route will store | Find who else writes to that route: an import, an admin screen, an API client. |
| A + tag or a long TLD is refused anywhere | Both are ordinary addresses | Raise it as a defect. Step 2 has user+tag@example.com passing both judges. |
| The field holds fewer characters than were typed | The value was sanitized before anything read it | Compare el.value.length against what you typed, as step 5 does. |
| A comma separated pair passes | The field carries multiple | Read what the route does with the list. It arrives as one string, commas included. |
| A trailing comma fails a multiple field | An empty item is not a valid address | Decide what the route does with an empty recipient before the form is changed. |
| Every row agrees | The two rules match on this list | Extend the list. Agreement on twelve addresses is not agreement on the grammar. |
Common mistakes
Thresholds
What to check next
- How to test that client validation is enforced on the server: the general case behind steps 3 and 4.
- How to test whitespace trimming in a form field: which layer removed the four characters step 5 lost.
- How to check field length limits in a form: the same boundary question, counted.
- How to check required field validation: the empty value this page leaves alone.
- Html input pattern validation: how to narrow the grammar measured here.
FAQ
How do I check email validation?
Run one list of addresses past every judge in the path and compare. Step 2 does it with two: validity.typeMismatch in the browser, and the rule on the route. A row where they differ is a defect in one of them.
What does html5 input type email validation accept?
One @, a local part of the allowed characters, and a domain of letters, digits and hyphens. No dot is required, no length is capped, and no non-ASCII character is allowed. Step 2 puts those boundaries on real addresses.
How do I do email validation in html?
Set type="email" on the input, add multiple for a list, and narrow it with pattern where the product needs a narrower rule. None of that constrains a request made outside the form.
Does any of this prove the address works?
No. Every column here is about shape. A mailbox that exists, a domain that accepts mail, and an inbox that receives it are three further questions. A confirmation mail with a link answers them.
Verified
Verified by Maks VernyChrome 152.0.7977.76puppeteer-core 25.10.0node 22.23.2curl 8.21.0Windows 11 build 22631
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate10 minpublished updated Maks Verny