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

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

  1. Step 1.

    Read the verdict and the message the browser would show, for three addresses.

    node probe.mjs one
    
    value "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@b is 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 in validationMessage, the text the person filling the form sees.

  2. Step 2.

    Send the whole list past the browser and the route in one pass.

    node probe.mjs judge
    
    qa@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+0065

    Seven rows of twelve disagree, in both directions. The browser is wider on a@b, on user@localhost and 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.

  3. 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.

  4. 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.

  5. Step 5.

    Type an address with padding around it, then submit the form.

    node probe.mjs typed
    
    typed     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 many line is the empty second field.

  6. Step 6.

    Put the same strings into a field that carries multiple.

    node probe.mjs multiple
    
    of 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

Sign: type=email is on the field, so address validation is recorded as covered.Cause: The grammar is wider than any mail system. a@b, user@localhost and a 320 character address all read as valid in step 2. A route that trusts the attribute stores all three, and the browser never asked whether the domain can receive mail.
Sign: A padded address is stored clean, and the route is credited with trimming it.Cause: The field did that. Step 5 typed 18 characters and the field held 14, before any request existed. Send the padded string with curl, the way step 3 sends its own, to find out what the route does with whitespace nothing has removed for it.
Sign: An address the browser refuses is dropped from the test data as invalid.Cause: Step 2 has four that the route accepts: a trailing dot, a doubled dot, a bracketed IP literal and élève@example.com. Those rows are the ones worth a defect, because the value can still arrive from an import or an API client that never met the form.
Sign: The fixture list is ASCII, so it agrees with itself everywhere.Cause: A local part written with U+00E9 sets typeMismatch in Chrome and is accepted by the route in step 2. That pair is invisible to an ASCII list, and it decides whether a person whose name is not spelled in ASCII can register at all.

Thresholds

254 characters is the longest address an SMTP path can carry, and the field accepted 320 Source: RFC 5321 section 4.5.3.1.3 caps a forward-path at 256 octets including the punctuation; the 320 figure is step 2 on Chrome 152.0.7977.76, 2026-09-12
the browser's grammar requires no dot in the domain: a@b, three characters, reported typeMismatch false Source: Measured in step 1, Chrome 152.0.7977.76, 2026-09-12

What to check next

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.

intermediate10 minpublished updated Maks Verny