Validate an email address without sending a message

Check that an address typed into a form has the shape of an address, before storing it.

No AI needed

FreeInstantYour data never leaves

The verdict

N0, because the definition is published and it is the one your visitor has just been put through in their browser: a server applying anything else refuses what the field accepted, or the reverse. This entry measures it: across thirty-two addresses, email-validator and validator.js return four verdicts that differ from each other, and fourteen that differ from the browser’s definition — including a sixty-five-character local part that email-validator accepts although RFC 5321 caps it at sixty-four. What this level does not do, it says: the report returns routable false on a domain with no dot, and what comes next — does the domain take mail — is a DNS lookup, not one more level.

The HTML standard’s definition, plus the RFC 5321 lengths

No AI needed

Rule and classic algorithmN0FreeInstant

Python

snippets/validate-an-email-address-without-sending-a-message/n0.pyTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
"""
Check an email address against the definition the browser already applied.

Rung N0, and the whole of it: nothing here touches the network, so nothing here
knows whether the mailbox exists. What it knows is the shape.

The shape is specified. The HTML standard defines a valid e-mail address for
`input type=email` and publishes the regular expression for it, adding that
"this requirement is a willful violation of RFC 5322". That violation is the
point: it is the definition your users were already validated against in their
browser, so it is the one a server should agree with.

The mature libraries do not agree with it, nor with each other. On the
thirty-two addresses of the test, `email-validator` 2.3.0 in Python and
`validator.js` 13.15.35 in JavaScript return different verdicts four times,
and the browser's definition differs from one or the other fourteen times.
Among the four: the quoted form "jean dupont"@exemple.fr, a single-letter top
level, and a sixty-five-character local part, which `email-validator` accepts
although RFC 5321 caps it at sixty-four — which is why the length is checked
here rather than left to a library.

Two things the report says rather than decides. The local part is left in its
case, because RFC 5321 makes it case-sensitive and only the domain is not;
lowercasing the whole address is a silent rewrite of somebody's mailbox name.
And `routable` is false for jean@localhost, which the HTML definition accepts:
a bare host name is a valid address and no public mail server will reach it.
"""

from __future__ import annotations

import re

# The regular expression published by the HTML standard for input type=email,
# transcribed character for character.
WHATWG = re.compile(
    r"^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+"
    r"@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?"
    r"(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$"
)

# RFC 5321, section 4.5.3.1: sixty-four octets for the local part, two hundred
# and fifty-six for the path, which leaves two hundred and fifty-four for the
# address itself once the angle brackets are counted.
MAX_LOCAL, MAX_ADDRESS = 64, 254

# The five characters the HTML standard calls ASCII whitespace. The vertical
# tab is not one of them, and `str.strip()` with no argument would remove it.
ASCII_WHITESPACE = "\t\n\f\r "


def check_email_syntax(raw) -> dict:
    """
    Say whether `raw` has the shape of an address, and what it normalises to.

    Nothing raises, and nothing is sent: a refusal comes back as `valid: False`
    with a `reason`, because a validator that throws in a request path leaves
    the form with nothing to show.
    """
    if not isinstance(raw, str):
        return _report(False, None, f"an address is text, not {type(raw).__name__}")

    # The HTML value sanitisation algorithm for an email field, in full: « Strip
    # newlines from the value, then strip leading and trailing ASCII whitespace
    # from the value. » Both halves matter, and in that order: an address pasted
    # from a signature carries a newline in the middle, which the browser
    # removes before validating; and the five ASCII whitespace characters do not
    # include the vertical tab, which the browser therefore refuses. A server
    # that does not do the same refuses what the browser accepted, or the other
    # way round.
    written = raw.replace("\n", "").replace("\r", "").strip(ASCII_WHITESPACE)
    if not WHATWG.match(written):
        return _report(False, None, "does not match the HTML definition of an email address")

    local, _, domain = written.rpartition("@")
    if len(local) > MAX_LOCAL:
        return _report(False, None, f"the part before the @ is over {MAX_LOCAL} characters")
    # Only the domain is case-insensitive. The local part belongs to the
    # receiving server, and RFC 5321 says it may tell Jean from jean.
    normalised = f"{local}@{domain.lower()}"
    if len(normalised) > MAX_ADDRESS:
        return _report(False, None, f"over {MAX_ADDRESS} characters")
    return _report(True, normalised, None)


def _report(valid: bool, normalised: str | None, reason: str | None) -> dict:
    local, _, domain = (normalised or "").rpartition("@")
    return {
        "valid": valid,
        "normalised": normalised,
        "local": local or None,
        "domain": domain or None,
        # A domain with no dot is a host name on the local network. The HTML
        # definition allows it; public mail does not reach it. That is all this
        # field says: `jean@1.2.3.4` has three dots and comes back routable,
        # because nothing here resolves anything.
        "routable": bool(domain) and "." in domain,
        "reason": reason,
    }

JavaScript

snippets/validate-an-email-address-without-sending-a-message/n0.jsTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
/**
 * Check an email address against the definition the browser already applied.
 *
 * Rung N0, and the whole of it: nothing here touches the network, so nothing
 * here knows whether the mailbox exists. What it knows is the shape.
 *
 * The shape is specified. The HTML standard defines a valid e-mail address for
 * `input type=email` and publishes the regular expression for it, adding that
 * "this requirement is a willful violation of RFC 5322". That violation is the
 * point: it is the definition your users were already validated against in
 * their browser, so it is the one a server should agree with.
 *
 * The mature libraries do not agree with it, nor with each other. On the
 * thirty-two addresses of the test, `validator.js` 13.15.35 in JavaScript and
 * `email-validator` 2.3.0 in Python return different verdicts four times, and
 * the browser's definition differs from one or the other fourteen times.
 * Among the four: the quoted form "jean dupont"@exemple.fr, a single-letter
 * top level, and a sixty-five-character local part, which `email-validator`
 * accepts although RFC 5321 caps it at sixty-four — which is why the length is
 * checked here rather than left to a library.
 *
 * Two things the report says rather than decides. The local part is left in
 * its case, because RFC 5321 makes it case-sensitive and only the domain is
 * not; lowercasing the whole address is a silent rewrite of somebody's mailbox
 * name. And `routable` is false for jean@localhost, which the HTML definition
 * accepts: a bare host name is a valid address and no public mail server will
 * reach it.
 */

// The regular expression published by the HTML standard for input type=email,
// transcribed character for character.
export const WHATWG =
  /^[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])?)*$/;

// RFC 5321, section 4.5.3.1: sixty-four octets for the local part, two hundred
// and fifty-six for the path, which leaves two hundred and fifty-four for the
// address itself once the angle brackets are counted.
export const MAX_LOCAL = 64;

// The five characters the HTML standard calls ASCII whitespace. The vertical
// tab is not one of them, and `String.prototype.trim` would remove it.
const ASCII_WHITESPACE = /^[\t\n\f\r ]+|[\t\n\f\r ]+$/g;
const NEWLINES = /[\n\r]/g;
export const MAX_ADDRESS = 254;

/**
 * Say whether `raw` has the shape of an address, and what it normalises to.
 *
 * Nothing throws, and nothing is sent: a refusal comes back as `valid: false`
 * with a `reason`, because a validator that throws in a request path leaves
 * the form with nothing to show.
 *
 * @param {string} raw
 */
export function checkEmailSyntax(raw) {
  if (typeof raw !== 'string') {
    return report(false, null, `an address is text, not ${kindOf(raw)}`);
  }

  // The HTML value sanitisation algorithm for an email field, in full: « Strip
  // newlines from the value, then strip leading and trailing ASCII whitespace
  // from the value. » Both halves matter, and in that order: an address pasted
  // from a signature carries a newline in the middle, which the browser removes
  // before validating; and the five ASCII whitespace characters do not include
  // the vertical tab, which the browser therefore refuses. A server that does
  // not do the same refuses what the browser accepted, or the other way round.
  const written = raw.replace(NEWLINES, '').replace(ASCII_WHITESPACE, '');
  if (!WHATWG.test(written)) {
    return report(false, null, 'does not match the HTML definition of an email address');
  }

  const at = written.lastIndexOf('@');
  const local = written.slice(0, at);
  const domain = written.slice(at + 1);
  if (local.length > MAX_LOCAL) {
    return report(false, null, `the part before the @ is over ${MAX_LOCAL} characters`);
  }
  // Only the domain is case-insensitive. The local part belongs to the
  // receiving server, and RFC 5321 says it may tell Jean from jean.
  const normalised = `${local}@${domain.toLowerCase()}`;
  if (normalised.length > MAX_ADDRESS) {
    return report(false, null, `over ${MAX_ADDRESS} characters`);
  }
  return report(true, normalised, null);
}

const kindOf = (value) => (value === null ? 'null' : typeof value);

function report(valid, normalised, reason) {
  const at = (normalised ?? '').lastIndexOf('@');
  const local = at > 0 ? normalised.slice(0, at) : null;
  const domain = at >= 0 ? normalised.slice(at + 1) : null;
  return {
    valid,
    normalised,
    local,
    domain: domain || null,
    // A domain with no dot is a host name on the local network. The HTML
    // definition allows it; public mail does not reach it. That is all this
    // field says: `jean@1.2.3.4` has three dots and comes back routable,
    // because nothing here resolves anything.
    routable: Boolean(domain) && domain.includes('.'),
    reason,
  };
}

Try it

interactive

The code above runs in your browser. Type in the box: the result is redone in front of you.

Type an address. The check is the HTML standard’s, the one your browser already applies to the field: nothing goes out on the network, and nothing says whether the mailbox exists.

yours to edit

Recomputed on every keystroke.

Or start from an example

Free typing and the other examples need JavaScript. The example shown below was computed when the site was built.

What the code makes of it

What it returns

{ "valid": true, "normalised": "Jean.DUPONT@exemple.fr", "local": "Jean.DUPONT", "domain": "exemple.fr", "routable": true, "reason": null }

What it concludes

Valid shape

Normalised to Jean.DUPONT@exemple.fr. The domain carries a dot: nothing here says it exists, only that it is not a local host name.

What this means for you

  • Your data stays put
  • Always the same result
  • Unit-testable, case by case
  • No provider in the loop
  • Negligible footprint

What this adds to your regulatory scope

  • No specific scope added: the address is checked inside your own process and is sent to nobody
  • An email address is personal data: checking it locally discloses nothing, submitting it to an online verification service discloses it

Breaking point

Syntax says nothing about the mailbox: jean@gmial.com passes, and the domain is a typo for gmail.com — the shape is identical, the message leaves and never comes back. The witness is in the same test: jean@@exemple.fr, jean@-exemple.fr and jean@exemple..fr are properly refused.

When to move up a level

Your users report an address refused that should not be, and it is the internationalised one — jéan@exemple.fr — which the HTML definition turns down, as every browser does. What follows is RFC 6531, not a model.

The four options

  1. RecommendedN0The HTML standard’s definition, plus the RFC 5321 lengthsFree · Instant
  2. N1Not worth it here
  3. N2Not worth it here
  4. N3Not worth it here

The other options

N1Not worth it here

Not worth it here

Lightweight classic modelN1

There is nothing to learn: the shape of an address is a published grammar, not a statistical regularity, and a classifier trained on labelled addresses would return a score where a regular expression returns a decision you can read back. The one judgement a classic model might add — this domain looks like a typo for a large provider — is an edit distance against a list of known domains, which is ordinary code again.

N2Not worth it here

Not worth it here

Small self-hosted specialised modelN2

The same argument, with a service to keep running on top. And the objection specific to this need: the check runs on every keystroke in a field, in the browser included. A small specialised model would add a load, resident memory and an inference per character typed, to approximate a grammar the browser already applies for free.

N3Not worth it here

Not worth it here

General-purpose LLM APIN3

One model call per address typed costs a network round trip, latency inside a form field, and personal data sent to a third party, to approximate a published regular expression. Nor does it know what syntax cannot: whether the domain exists is settled in DNS, whether the mailbox exists is proved by writing to it, and a model asserting either would be making it up.

Further reading

Where the figures come from

Metadata