Functional Weave
Code in Rust

auth.normalise-email

Trim an email address and lower-case its domain for storage and lookup, or null if it is not a plausible address.

1.0.0 · published 2026-10-03 by charlie · Anterra

Pinned by 12 tests, run in TypeScript, Python and Rust.

What it does

`normaliseEmail(" Alice@Example.COM ")` is `"Alice@example.com"`. Call it on every address before it is stored and before it is looked up, at sign-up, at login and anywhere else, so that one person's address has one spelling in the database. It answers `null` when the trimmed text is not an address that `validation.email` accepts, so a sign-up form or API can report the field in the same step.

**What changes.** Leading and trailing ASCII whitespace (space, tab, line feed, carriage return, form feed, vertical tab) is removed, since pasted addresses often carry it. The domain is lower-cased: domain names are case-insensitive (RFC 4343), so `Example.COM` and `example.com` are the same mailbox host.

For example

  • normalise_email( Alice@Example.COM ) → Alice@example.com surrounding spaces are trimmed and the domain lower-cased; the local part keeps its case
  • normalise_email(alice@example.com) → alice@example.com an address already in stored form is unchanged
  • normalise_email( bob@EXAMPLE.org ) → bob@example.org a tab and a trailing newline from a paste

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.

pub fn normalise_email(value: &str) -> Option<String>
valuestringthe address as typed into a form
returnsstring?the stored form: trimmed, domain lower-cased, local part as typed; null if not an address

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

fune!(auth.normalise-email@^1);  // then call normalise_email(…)
impl/rust.rs · 25 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::validation_email::is_email;  ← from validation.email ^1.0.0 · built alongside by fune

fn is_ascii_space(ch: char) -> bool {
    matches!(ch, ' ' | '\t' | '\n' | '\r' | '\u{0C}' | '\u{0B}')
}

/// The stored form of an email address: trimmed, domain lower-cased, local
/// part as typed; `None` when the trimmed text is not a plausible address.
pub fn normalise_email(value: &str) -> Option<String> {
    // ASCII whitespace only; str::trim also strips Unicode spaces.
    let trimmed = value.trim_matches(is_ascii_space);
    if !is_email(trimmed) {
        return None;
    }
    let at = trimmed.find('@').unwrap();
    Some(format!("{}{}", &trimmed[..=at], trimmed[at + 1..].to_ascii_lowercase()))
}

pub fn fune_vector(args: &[Value]) -> Value {
    match normalise_email(args[0].as_str()) {
        Some(email) => Value::Str(email),
        None => Value::Null,
    }
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 1 dependency, pins them in fune.lock, downloads only the Rust 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. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:

fune add auth.normalise-email
Download for Rust auth.normalise-email-1.0.0-rust.fune · 5,405 bytes sha256 1004f6bc3b133763ea631bdabe644c59cd6318d0e2897546c756659de8edb445

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

The whole function, every language, is one file too: auth.normalise-email-1.0.0.fune, 7,359 bytes, sha256 40e4714dd4327fd6b0ec3548c5d72ed135e2133a354b5183a9ec1384fcbd6626. 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 auth.normalise-email

after — your function gets the result and the arguments, and returns the final result.

// fune: after auth.normalise-email

replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.

// fune: replace validation.email in auth.normalise-email

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 auth.normalise-email --steps.

// fune: step auth.normalise-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
surrounding spaces are trimmed and the domain lower-cased; the local part keeps its case Alice@Example.COM → Alice@example.com
an address already in stored form is unchanged alice@example.com → alice@example.com
a tab and a trailing newline from a paste bob@EXAMPLE.org → bob@example.org
dots and a +tag in the local part are kept, not folded Alice.Smith+News@Mail.Example.CO.UK → Alice.Smith+News@mail.example.co.uk
the shortest address accepted a@B.CO → a@b.co
the empty string is not an address → —
only whitespace is not an address → —
text with no at sign not an email → —
a space inside the address is not trimmed away alice @example.com → —
a bare host name is not deliverable from outside alice@localhost → —
Show the other 2 tests
CaseArgumentsExpected
a trailing no-break space is not ASCII whitespace, so the address is refused alice@example.com  → —
two at signs alice@example@com → —

More from the author

**What does not.** The local part, before the `@`, is kept exactly as typed. RFC 5321 section 2.4 says it MAY be case-sensitive and that only the receiving host can decide, so lower-casing it could, in principle, merge two people's accounts. In practice nearly every provider treats it case-insensitively, so `Alice@example.com` and `alice@example.com` will be two different accounts here; an application that wants them merged should lower-case the whole address itself and say so. Dots and `+tags` are also kept (`a.b+news@gmail.com` is not rewritten to `ab@gmail.com`): that folding is one provider's rule, not a property of email.

Non-ASCII whitespace such as a no-break space is not trimmed, and the address is then refused, since `validation.email` accepts ASCII only. Internationalised domains must arrive punycoded (`xn--...`).

Files

PathBytes
README.md1,569
impl/python.py725
impl/rust.rs857
impl/typescript.ts1,099
vectors.json1,370