Functional Weave
Code in Python

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

  • is_email(alice@example.com) → true a plain address
  • is_email(first.last@mail.example-corp.co.uk) → true a subdomain and a hyphen in the domain
  • is_email(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.

def is_email(value: str) -> bool
valuestringThe candidate address, exactly as typed; no trimming is applied
returnsbool

Your code names it in one line, in the file that uses it

from fune.validation.email import is_email  # validation.email@^1
impl/python.py · 131 lines · open · raw
from typing import Optional

# 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.
_MAX_LOCAL = 64
#: RFC 5321 caps a forward path at 256 octets including the angle brackets.
_MAX_TOTAL = 254
#: RFC 1035 caps a DNS label at 63 octets.
_MAX_LABEL = 63

#: The "atext" specials from RFC 5322, plus the dot handled separately below.
_LOCAL_SPECIALS = "!#$%&'*+-/=?^_`{|}~"


def _is_digit(ch: str) -> bool:
    return "0" <= ch <= "9"


def _is_letter(ch: str) -> bool:
    return ("a" <= ch <= "z") or ("A" <= ch <= "Z")


def _is_letter_or_digit(ch: str) -> bool:
    return _is_letter(ch) or _is_digit(ch)


def _is_local_char(ch: str) -> bool:
    return _is_letter_or_digit(ch) or ch in _LOCAL_SPECIALS


def is_email(value: str) -> bool:
    """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.
    """
    if not isinstance(value, str):
        return False
    if len(value) == 0 or len(value) > _MAX_TOTAL:
        return False

    # Exactly one @: the last-@ split used by lenient parsers quietly accepts
    # "a@b@c", which no MTA will route.
    at = -1
    for i, ch in enumerate(value):
        if ch == "@":
            if at != -1:
                return False
            at = i
    if at <= 0 or at == len(value) - 1:
        return False

    return _is_local_part(value[:at]) and _is_domain(value[at + 1 :])


def _is_local_part(local: str) -> bool:
    if len(local) == 0 or len(local) > _MAX_LOCAL:
        return False
    # A dot is a separator between atoms, so it cannot lead, trail or double up.
    if local[0] == "." or local[-1] == ".":
        return False
    for i, ch in enumerate(local):
        if ch == ".":
            if local[i - 1] == ".":
                return False
            continue
        if not _is_local_char(ch):
            return False
    return True


def _is_domain(domain: str) -> bool:
    # The total-length cap already bounds this, but stating the domain limit
    # separately keeps the rule readable and survives a change to the total.
    if len(domain) == 0 or len(domain) > _MAX_TOTAL - 2:
        return False

    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 len(labels) < 2:
        return False

    for label in labels:
        if len(label) == 0 or len(label) > _MAX_LABEL:
            return False
        if label[0] == "-" or label[-1] == "-":
            return False
        for ch in label:
            if not _is_letter_or_digit(ch) and 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.
    tld = labels[-1]
    if len(tld) < 2:
        return False
    for ch in tld:
        if not _is_letter(ch):
            return False
    return True


def email_domain(value: str) -> Optional[str]:
    """The domain half of an address, lowercased, or None 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.
    """
    if not is_email(value):
        return None
    domain = value[value.index("@") + 1 :]
    out = []
    for ch in domain:
        out.append(chr(ord(ch) + 32) if "A" <= ch <= "Z" else ch)
    return "".join(out)

Install

fune build

With that line in your source, in a Python project (language python in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the Python 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
Download for Python validation.email-1.0.0-python.fune · 12,284 bytes sha256 c5018c7fb6ba727272a341dbc93a60f878acf54c145195adb9fb44638cf86bd0

The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./validation.email-1.0.0-python.fune, or fetch it from a terminal with fune pull validation.email@1.0.0:python.

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.

CaseArgumentsExpected
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
CaseArgumentsExpected
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

PathBytes
README.md2,558
impl/python.py4,410
impl/rust.rs5,412
impl/typescript.ts4,598
vectors.json3,553