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 casenormalise_email(alice@example.com)→ alice@example.com an address already in stored form is unchangednormalise_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>
| value | string | the address as typed into a form |
| returns | string? | 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(…)
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
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.
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Path | Bytes |
|---|---|
| README.md | 1,569 |
| impl/python.py | 725 |
| impl/rust.rs | 857 |
| impl/typescript.ts | 1,099 |
| vectors.json | 1,370 |