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
normaliseEmail( Alice@Example.COM )→ Alice@example.com surrounding spaces are trimmed and the domain lower-cased; the local part keeps its casenormaliseEmail(alice@example.com)→ alice@example.com an address already in stored form is unchangednormaliseEmail( 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.
export function normaliseEmail(value: string): string | null
| 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
import { normaliseEmail } from "#fune/auth.normalise-email@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { isEmail } from "./validation_email.ts"; ← from validation.email ^1.0.0 · built alongside by fune
/** ASCII whitespace only: String.prototype.trim also strips no-break spaces and other Unicode spaces, which Python and Rust would each treat differently. */
function isAsciiSpace(ch: string): boolean {
return ch === " " || ch === "\t" || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
}
/**
* The stored form of an email address: trimmed, domain lower-cased, local
* part as typed; null when the trimmed text is not a plausible address.
*/
export function normaliseEmail(value: string): string | null {
if (typeof value !== "string") return null;
let start = 0;
let end = value.length;
while (start < end && isAsciiSpace(value[start])) start++;
while (end > start && isAsciiSpace(value[end - 1])) end--;
const trimmed = value.slice(start, end);
if (!isEmail(trimmed)) return null;
const at = trimmed.indexOf("@");
let domain = "";
for (const ch of trimmed.slice(at + 1)) {
domain += ch >= "A" && ch <= "Z" ? String.fromCharCode(ch.charCodeAt(0) + 32) : ch;
}
return trimmed.slice(0, at + 1) + domain;
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 1 dependency, pins them in fune.lock, downloads only the TypeScript 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 auth.normalise-email
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./auth.normalise-email-1.0.0-typescript.fune, or fetch it from a terminal with fune pull auth.normalise-email@1.0.0:typescript.
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 |