structured-email-address 0.0.19

RFC 5321/5322/6531 email address parser, validator, and normalizer. Subaddress extraction, provider-aware normalization, PSL domain validation, anti-homoglyph protection.
Documentation

structured-email-address

RFC 5321/5322/6531 conformant email address parser, validator, and normalizer for Rust.

CI Crates.io docs.rs License: Apache-2.0

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 structured_email_address::{EmailAddress, Config};

// Parse with defaults (RFC 5322 Standard mode)
let email: EmailAddress = "user+tag@example.com".parse()?;
assert_eq!(email.local_part(), "user+tag");
assert_eq!(email.tag(), Some("tag"));
assert_eq!(email.domain(), "example.com");

// International domains: IDNA roundtrip
let email: EmailAddress = "user@münchen.de".parse()?;
assert_eq!(email.domain(), "xn--mnchen-3ya.de");
assert_eq!(email.domain_unicode(), "münchen.de");

Configured Parsing

use structured_email_address::{EmailAddress, Config};

let config = 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 = EmailAddress::parse_with("A.L.I.C.E+promo@Gmail.COM", &config)?;
assert_eq!(email.canonical(), "alice@gmail.com");
assert_eq!(email.tag(), Some("promo"));
assert!(email.is_freemail());

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 structured_email_address::{Config, EmailAddress, ProviderRule};

let config = Config::builder()
    .provider_aware()            // matched provider's rule governs the address
    .strip_subaddress()
    .add_provider(               // extend the built-in registry
        ProviderRule::new(["mail.corp.example"])
            .strip_dots(true)
            .lowercase_local(true)
            .subaddress_separator(Some('-')),
    )
    .build();

// Gmail's built-in rule strips dots + folds case even with no global policy set:
let g = EmailAddress::parse_with("A.Li.Ce+promo@Gmail.com", &config)?;
assert_eq!(g.canonical(), "alice@gmail.com");

// Custom provider with a '-' separator:
let c = EmailAddress::parse_with("John.Doe-tag@mail.corp.example", &config)?;
assert_eq!(c.local_part(), "johndoe");
assert_eq!(c.tag(), Some("tag"));

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 structured_email_address::{EmailAddress, Config};

let config = Config::builder().allow_display_name().build();
let email = EmailAddress::parse_with("John Doe <user@example.com>", &config)?;
assert_eq!(email.display_name(), Some("John Doe"));

Batch Parsing

Parse thousands of addresses in one call. Config is shared, results preserve input order:

use structured_email_address::{EmailAddress, Config};

let config = Config::builder().strip_subaddress().lowercase_all().build();
let results = EmailAddress::parse_batch(
    &["alice@example.com", "invalid", "bob+tag@example.org"],
    &config,
);
assert!(results[0].is_ok());
assert!(results[1].is_err());
assert!(results[2].is_ok());

For large lists (10K+), enable the rayon feature for parallel parsing:

structured-email-address = { version = "0.0.1", features = ["rayon"] }
let results = EmailAddress::parse_batch_par(&huge_list, &config);

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 structured_email_address::{Config, EmailAddress, Strictness};

// RFC 5321 §4.1.2 Mailbox as written: Dot-string or Quoted-string, no comments.
let mailbox = Config::builder()
    .strictness(Strictness::Strict)
    .allow_quoted_local_part()
    .build();

let email = EmailAddress::parse_with("\"a b\"@example.com", &mailbox).unwrap();
assert_eq!(email.local_part(), "a b");
assert_eq!(email.canonical(), "\"a b\"@example.com");

// The alphabet is the envelope one, so header syntax stays out.
assert!(EmailAddress::parse_with("a(comment)@example.com", &mailbox).is_err());

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 structured_email_address::{Config, DomainScope, EmailAddress, IpScope, LiteralScope};

let config = Config::builder()
    .allow_single_label_domain()
    .allow_address_literal_rfc5321()
    .build();
let scope = |input| EmailAddress::parse_with(input, &config).unwrap().domain_scope();

assert_eq!(scope("a@example.com"), DomainScope::Global);
assert_eq!(scope("admin@printer"), DomainScope::Local);
assert_eq!(scope("a@files.local"), DomainScope::Local);
assert_eq!(
    scope("a@[192.168.1.5]"),
    DomainScope::Literal(LiteralScope::Ipv4(IpScope::Local)),
);
assert!(scope("a@[192.0.2.1]").is_global());

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)
structured-email-address = { version = "0.0.17", default-features = 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.

structured-email-address = { version = "0.0.17", default-features = false, features = ["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 structured_email_address::confusable_skeleton;

// Cyrillic 'а' (U+0430) vs Latin 'a' (U+0061)
assert_eq!(
    confusable_skeleton("аlice"),  // Cyrillic а
    confusable_skeleton("alice"),  // Latin a
);

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 Donation QR Code

USDT (TRC-20): TFDsezHa1cBkoeZT5q2T49Wp66K8t2DmdA

License

Apache License 2.0