structured-email-address
RFC 5321/5322/6531 conformant email address parser, validator, and normalizer for Rust.
What makes this different?
Every Rust email crate stops at RFC validation. This one goes further:
| Feature | email_address |
email-address-parser |
This crate |
|---|---|---|---|
| RFC 5322 grammar | Partial | Full | Full |
| RFC 6531 (UTF-8) | Yes | Yes | Yes |
| Subaddress/+tag extraction | - | - | Yes |
| Provider-aware dot-stripping | - | - | Yes |
| Configurable case folding | - | - | Yes |
| PSL domain validation | - | - | Yes |
| Anti-homoglyph detection | - | - | Yes |
| IDN domain Unicode accessor | - | - | Yes |
| Global vs local domain scope | - | - | Yes |
| Display name parsing | Yes | - | Yes |
| Configurable strictness | Partial | Partial | Full |
| Serde support | Yes | - | Yes |
| Zero dependencies* | Yes | nom | idna + 3 |
* Dependencies: idna, unicode-normalization, unicode-security. Optional: structured-public-domains, serde.
Quick Start
use ;
// Parse with defaults (RFC 5322 Standard mode)
let email: EmailAddress = "user+tag@example.com".parse?;
assert_eq!;
assert_eq!;
assert_eq!;
// International domains: IDNA roundtrip
let email: EmailAddress = "user@münchen.de".parse?;
assert_eq!;
assert_eq!;
Configured Parsing
use ;
let config = builder
.strip_subaddress // user+tag → user
.dots_gmail_only // a.l.i.c.e@gmail.com → alice@gmail.com
.lowercase_all // USER → user
.check_confusables // detect Cyrillic lookalikes
.domain_check_psl // verify domain in Public Suffix List
.build;
let email = parse_with?;
assert_eq!;
assert_eq!;
assert!;
Provider-Aware Normalization
Each known provider carries its own rule (dot handling, case folding, subaddress
separator, freemail flag). Enable provider_aware() to normalize a matched
address by its provider's rule instead of the global policies, and register your
own providers:
use ;
let config = builder
.provider_aware // matched provider's rule governs the address
.strip_subaddress
.add_provider
.build;
// Gmail's built-in rule strips dots + folds case even with no global policy set:
let g = parse_with?;
assert_eq!;
// Custom provider with a '-' separator:
let c = parse_with?;
assert_eq!;
assert_eq!;
Built-in providers: Gmail/Googlemail (dot-stripping), Outlook, Yahoo, ProtonMail,
iCloud, Yandex, Mail.ru, and other common freemail domains. is_freemail()
consults the same registry regardless of provider_aware.
Display Names
use ;
let config = builder.allow_display_name.build;
let email = parse_with?;
assert_eq!;
Batch Parsing
Parse thousands of addresses in one call. Config is shared, results preserve input order:
use ;
let config = builder.strip_subaddress.lowercase_all.build;
let results = parse_batch;
assert!;
assert!;
assert!;
For large lists (10K+), enable the rayon feature for parallel parsing:
= { = "0.0.1", = ["rayon"] }
let results = parse_batch_par;
Batch Benchmarks (baseline)
100K emails (mix of valid + invalid), strip_subaddress + dots_gmail_only + lowercase_all config.
Apple M1 Pro, Rust 1.85, cargo bench --all-features.
| Variant | Time | Throughput |
|---|---|---|
parse_batch (sequential) |
49.1 ms | ~2.0M emails/sec |
parse_batch_par (rayon) |
9.6 ms | ~10.4M emails/sec |
Rayon gives ~5x speedup on this workload.
Strictness Levels
| Level | Grammar | Use case |
|---|---|---|
Strict |
RFC 5321 (envelope) | SMTP validation, reject exotic addresses |
Standard |
RFC 5322 (header) | Default — full grammar, no obsolete forms |
Lax |
RFC 5322 + obs-* | Legacy system interop |
Strict refuses a quoted local part, because an address like "a b"@example.com
is valid and unroutable in practice. A consumer reading an identity rather than
routing to it needs the alternative back, and asks for it:
use ;
// RFC 5321 §4.1.2 Mailbox as written: Dot-string or Quoted-string, no comments.
let mailbox = builder
.strictness
.allow_quoted_local_part
.build;
let email = parse_with.unwrap;
assert_eq!;
assert_eq!;
// The alphabet is the envelope one, so header syntax stays out.
assert!;
Domain Scope
A parse says whether the text is an address the configured grammar names. It
does not say whether the domain reaches anywhere: admin@printer,
postmaster@files.local and a@[192.168.1.5] are as well formed as
a@example.com, and what separates them is reach, not syntax.
use ;
let config = builder
.allow_single_label_domain
.allow_address_literal_rfc5321
.build;
let scope = ;
assert_eq!;
assert_eq!;
assert_eq!;
assert_eq!;
assert!;
Local covers a single label and the names reserved by RFC 6761 (.test,
.example, .invalid, .localhost), RFC 6762 (.local), RFC 8375
(home.arpa), ICANN (.internal), and the two the DNS never resolves at all,
RFC 7686 (.onion) and RFC 9476 (.alt) — matched on whole labels, so
notlocal.com is Global. A literal reports its family and whether the address
is one that stays inside a network: RFC 1918 and RFC 6598 for v4, fc00::/7 for
v6, the link-local and site-local ranges, loopback, the limited broadcast
address, the benchmarking block, and multicast below global scope. An address
that embeds an IPv4 one reports the reach of the address it holds.
Nothing here changes a verdict. A single-label domain stays refused unless
allow_single_label_domain asks for it; the classification is for the consumer
that has opted in and now has to tell the two apart. With the psl feature off,
Global weakens from "under a published public suffix" to "the final label is
TLD-like", so a no_std consumer gets an answer rather than a compile error.
Features
| Feature | Default | Description |
|---|---|---|
std |
Yes | Operating-system integration in dependencies. Turn it off for no_std |
alloc |
Yes (via std) |
Names the core + alloc build. An allocator is required either way |
serde |
Yes | Serialize/deserialize as canonical string |
psl |
Yes | Domain validation against Public Suffix List. Implies std until structured-public-domains ships its own no-std build |
rayon |
No | Parallel batch parsing via parse_batch_par() (implies std) |
# Minimal (no serde, no PSL)
= { = "0.0.17", = false }
no_std
The parser and validator build against core + alloc, so they run in a WASM
sandbox or on bare metal. Nothing here needs a pointer-width atomic either, so
the crate builds on targets without compare-and-swap. CI checks both
thumbv7em-none-eabihf and thumbv6m-none-eabi, because a host check cannot
fail on a constraint the host does not have: std is present there, so code
reaching for it still compiles, and the host has the atomics thumbv6m lacks.
= { = "0.0.17", = false, = ["alloc"] }
An allocator is not optional: every parse produces owned strings. The psl
feature declares std as a requirement, since structured-public-domains has
not made the same move yet; a no-std build therefore leaves PSL validation out
and keeps the rest.
Anti-Homoglyph Protection
Detects visually confusable email addresses using Unicode skeleton mapping:
use confusable_skeleton;
// Cyrillic 'а' (U+0430) vs Latin 'a' (U+0061)
assert_eq!;
Conformance
Validated against the isEmail test
suite (v3.05, 164 edge cases), the same corpus used by email-address-parser.
All 164 cases pass: valid addresses (RFC 5321/5322 quoted strings, IPv4/IPv6
address literals, comments, folding whitespace, obsolete forms) are accepted at
the appropriate strictness level, while malformed inputs (bad IP literals,
over-length parts, bare control characters) are rejected. See
tests/conformance.rs.
Support the Project
USDT (TRC-20): TFDsezHa1cBkoeZT5q2T49Wp66K8t2DmdA
License
Apache License 2.0