Skip to main content

structured_email_address/
lib.rs

1//! # structured-email-address
2//!
3//! RFC 5321/5322/6531 conformant email address parser, validator, and normalizer.
4//!
5//! Unlike existing Rust crates that stop at RFC validation, this crate provides:
6//! - **Subaddress extraction**: `user+tag@domain` → separate `user`, `tag`, `domain`
7//! - **Provider-aware normalization**: Gmail dot-stripping, configurable case folding
8//! - **PSL domain validation**: verify domain against the Public Suffix List
9//! - **Anti-homoglyph protection**: detect Cyrillic/Latin lookalikes via Unicode skeleton
10//! - **Domain scope**: whether the domain is one the global DNS can resolve or
11//!   one that means anything only inside a network
12//! - **Configurable strictness**: Strict (5321), Standard (5322), Lax (obs-* allowed)
13//! - **Zero-copy parsing**: internal spans into the input string
14//! - **`no_std` + `alloc`**: builds for WASM and bare metal; disable the `std`
15//!   feature and enable `alloc`
16//!
17//! # Quick Start
18//!
19//! ```
20//! use structured_email_address::{EmailAddress, Config};
21//!
22//! // Simple: parse with defaults
23//! let email: EmailAddress = "user+tag@example.com".parse().unwrap();
24//! assert_eq!(email.local_part(), "user+tag");
25//! assert_eq!(email.tag(), Some("tag"));
26//! assert_eq!(email.domain(), "example.com");
27//!
28//! // Configured: Gmail normalization pipeline
29//! let config = Config::builder()
30//!     .strip_subaddress()
31//!     .dots_gmail_only()
32//!     .lowercase_all()
33//!     .build();
34//!
35//! let email = EmailAddress::parse_with("A.L.I.C.E+promo@Gmail.COM", &config).unwrap();
36//! assert_eq!(email.canonical(), "alice@gmail.com");
37//! assert_eq!(email.tag(), Some("promo"));
38//! ```
39
40#![cfg_attr(
41    not(test),
42    deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)
43)]
44#![cfg_attr(not(feature = "std"), no_std)]
45
46extern crate alloc;
47
48use alloc::format;
49use alloc::string::{String, ToString};
50use alloc::vec::Vec;
51
52mod config;
53mod error;
54mod normalize;
55mod parser;
56mod provider;
57mod scope;
58mod validate;
59
60pub use config::{
61    AddressLiteral, CasePolicy, Config, ConfigBuilder, DomainCheck, DotPolicy, Strictness,
62    SubaddressPolicy,
63};
64pub use error::{Error, ErrorKind};
65pub use normalize::confusable_skeleton;
66pub use provider::{ProviderRegistry, ProviderRule};
67pub use scope::{DomainScope, IpScope, LiteralScope};
68
69/// Tests ASCII SMTP Mailbox syntax without allocating or normalizing input.
70///
71/// Use this when checking a borrowed identity rather than constructing an
72/// [`EmailAddress`]. Accepts the dot-string and quoted-string local parts of
73/// [RFC 5321 §4.1.2](https://www.rfc-editor.org/rfc/rfc5321#section-4.1.2),
74/// single-label domains, and the address-literal syntax of §4.1.3. Header
75/// display names, angle brackets, comments, folding whitespace and SMTPUTF8
76/// are refused. The empty quoted local part is syntactically permitted.
77///
78/// Applies the existing 64-octet local-part, 254-octet mailbox and 63-octet
79/// domain-label bounds. Inputs longer than 254 octets are refused before
80/// scanning; remaining work is linear in input length with bounded stack use.
81/// Both acceptance and refusal allocate no heap storage, including diagnostics.
82///
83/// This is a syntax test, not a deliverability or registry-membership check.
84/// General address-literal tags are checked syntactically; their registration
85/// and system-specific meaning must be established separately. No IDNA,
86/// provider rules, Unicode normalization, DNS or PSL lookup is performed.
87///
88/// # Examples
89///
90/// ```
91/// use structured_email_address::is_smtp_mailbox;
92/// assert!(is_smtp_mailbox("\"a b\"@localhost"));
93/// assert!(!is_smtp_mailbox("Alice <user@example.com>"));
94/// ```
95#[must_use]
96pub fn is_smtp_mailbox(input: &str) -> bool {
97    parser::is_smtp_mailbox(input)
98}
99
100/// A parsed, validated, and normalized email address.
101///
102/// Immutable after construction. All accessors return borrowed data.
103#[derive(Debug, Clone)]
104pub struct EmailAddress {
105    /// Original input, exactly as supplied to the parser.
106    original: String,
107    /// Canonical local part (after normalization).
108    local_part: String,
109    /// Extracted subaddress tag, if any.
110    tag: Option<String>,
111    /// Canonical domain (IDNA-encoded, lowercased).
112    domain: String,
113    /// Unicode form of the domain (only when domain has punycode labels).
114    domain_unicode: Option<String>,
115    /// Display name, if parsed from `name-addr` format.
116    display_name: Option<String>,
117    /// Confusable skeleton, if config enabled it.
118    skeleton: Option<String>,
119    /// Whether the domain is a known freemail provider (from the registry).
120    freemail: bool,
121}
122
123impl EmailAddress {
124    /// Parse and validate with the given configuration.
125    pub fn parse_with(input: &str, config: &Config) -> Result<Self, Error> {
126        let parsed = parser::parse(input, config)?;
127
128        let normalized = normalize::normalize(&parsed, config)?;
129        validate::validate(&parsed, &normalized, config)?;
130
131        // Freemail status comes from the provider registry (built-ins + any
132        // custom rules), independent of provider-aware normalization.
133        let freemail = config
134            .providers
135            .lookup(&normalized.domain)
136            .is_some_and(|p| p.is_freemail());
137
138        Ok(Self {
139            original: parsed.input.to_string(),
140            local_part: normalized.local_part,
141            tag: normalized.tag,
142            domain: normalized.domain,
143            domain_unicode: normalized.domain_unicode,
144            display_name: normalized.display_name,
145            skeleton: normalized.skeleton,
146            freemail,
147        })
148    }
149
150    /// The canonical local part (after normalization).
151    ///
152    /// If subaddress stripping is enabled, this excludes the `+tag`.
153    /// If dot stripping is enabled, dots are removed.
154    pub fn local_part(&self) -> &str {
155        &self.local_part
156    }
157
158    /// The extracted subaddress tag, if present.
159    ///
160    /// For `user+promo@example.com`, returns `Some("promo")`.
161    /// Always extracted regardless of [`SubaddressPolicy`] — the policy only
162    /// affects whether it appears in [`canonical()`](Self::canonical).
163    pub fn tag(&self) -> Option<&str> {
164        self.tag.as_deref()
165    }
166
167    /// The canonical domain (IDNA-encoded, lowercased).
168    ///
169    /// An address literal keeps its brackets and is not IDNA-encoded. An IP
170    /// literal is still lowercased, since neither the `IPv6:` tag nor a hex
171    /// digit carries case; a [`General-address-literal`] keeps the spelling it
172    /// was given, because its body is opaque to SMTP and meaningful only to the
173    /// receiving system.
174    ///
175    /// [`General-address-literal`]: AddressLiteral::Rfc5321
176    pub fn domain(&self) -> &str {
177        &self.domain
178    }
179
180    /// The canonical domain in Unicode form.
181    ///
182    /// For internationalized domains (`münchen.de` → `xn--mnchen-3ya.de`),
183    /// returns the Unicode form of the canonical domain. For ASCII-only
184    /// domains, returns the same value as [`domain()`](Self::domain).
185    ///
186    /// # Security
187    ///
188    /// The Unicode form is intended for **display only**. It may reintroduce
189    /// [IDN homograph attacks](https://en.wikipedia.org/wiki/IDN_homograph_attack)
190    /// where visually similar characters from different scripts produce
191    /// different domain names (e.g. Cyrillic `а` vs Latin `a`).
192    ///
193    /// For security-sensitive comparisons (allow-lists, deduplication, access
194    /// control), always use [`domain()`](Self::domain) which returns the
195    /// ACE/Punycode form. If you must compare Unicode domains, apply your own
196    /// confusable-detection logic (see [`confusable_skeleton()`]).
197    ///
198    /// ```
199    /// use structured_email_address::EmailAddress;
200    ///
201    /// let email: EmailAddress = "user@münchen.de".parse().unwrap();
202    /// assert_eq!(email.domain(), "xn--mnchen-3ya.de");
203    /// assert_eq!(email.domain_unicode(), "münchen.de");
204    ///
205    /// let ascii: EmailAddress = "user@example.com".parse().unwrap();
206    /// assert_eq!(ascii.domain_unicode(), "example.com");
207    /// ```
208    pub fn domain_unicode(&self) -> &str {
209        self.domain_unicode.as_deref().unwrap_or(&self.domain)
210    }
211
212    /// Whether the domain names something the global DNS can resolve, or
213    /// something that means anything only inside a network.
214    ///
215    /// A parse answers whether the text is an address the configured grammar
216    /// names. It does not answer this, which a consumer showing an address to a
217    /// person needs next: `admin@printer`, `postmaster@files.local` and
218    /// `a@[192.168.1.5]` are as well formed as `a@example.com`, and what
219    /// separates them is reach, not syntax.
220    ///
221    /// Computed from the canonical domain, so it changes no verdict: an address
222    /// that parses today parses the same way with or without this call. See
223    /// [`DomainScope`] for the reserved names and address ranges, each with the
224    /// document reserving it.
225    ///
226    /// Without the `psl` feature the reading of [`Global`](DomainScope::Global)
227    /// weakens from "under a published public suffix" to "the final label is
228    /// TLD-like", so a no-std consumer gets an answer rather than a compile
229    /// error.
230    ///
231    /// ```
232    /// use structured_email_address::{
233    ///     Config, DomainScope, EmailAddress, IpScope, LiteralScope,
234    /// };
235    ///
236    /// let config = Config::builder()
237    ///     .allow_single_label_domain()
238    ///     .allow_address_literal_rfc5321()
239    ///     .build();
240    /// let scope = |input| EmailAddress::parse_with(input, &config).unwrap().domain_scope();
241    ///
242    /// assert_eq!(scope("a@example.com"), DomainScope::Global);
243    /// assert_eq!(scope("admin@printer"), DomainScope::Local);
244    /// assert_eq!(scope("a@host.local"), DomainScope::Local);
245    /// assert_eq!(
246    ///     scope("a@[192.168.1.5]"),
247    ///     DomainScope::Literal(LiteralScope::Ipv4(IpScope::Local)),
248    /// );
249    /// assert!(scope("a@[192.0.2.1]").is_global());
250    /// ```
251    pub fn domain_scope(&self) -> DomainScope {
252        scope::classify(&self.domain)
253    }
254
255    /// The display name, if parsed from `"Name" <addr>` or `Name <addr>` format.
256    pub fn display_name(&self) -> Option<&str> {
257        self.display_name.as_deref()
258    }
259
260    /// The full canonical address: `local_part@domain`.
261    ///
262    /// If the local part contains characters that require quoting (spaces,
263    /// special chars), it is wrapped in quotes for RFC compliance.
264    pub fn canonical(&self) -> String {
265        if needs_quoting(&self.local_part) {
266            let escaped = escape_local_part(&self.local_part);
267            format!("\"{}\"@{}", escaped, self.domain)
268        } else {
269            format!("{}@{}", self.local_part, self.domain)
270        }
271    }
272
273    /// The original input, exactly as supplied to the parser (not trimmed).
274    pub fn original(&self) -> &str {
275        &self.original
276    }
277
278    /// The confusable skeleton of the local part (if config enabled it).
279    ///
280    /// Two addresses with the same skeleton + domain are visually confusable.
281    pub fn skeleton(&self) -> Option<&str> {
282        self.skeleton.as_deref()
283    }
284
285    /// Check if the domain is a known freemail provider.
286    ///
287    /// Determined from the [`ProviderRegistry`] in the [`Config`] used to parse
288    /// (built-in providers plus any registered via
289    /// [`ConfigBuilder::add_provider`]).
290    pub fn is_freemail(&self) -> bool {
291        self.freemail
292    }
293
294    /// Parse a batch of email addresses with the given configuration.
295    ///
296    /// Returns one `Result` per input, in the same order. The config is
297    /// shared across all inputs, amortizing setup cost.
298    ///
299    /// # Example
300    ///
301    /// ```
302    /// use structured_email_address::{EmailAddress, Config};
303    ///
304    /// let config = Config::default();
305    /// let results = EmailAddress::parse_batch(
306    ///     &["alice@example.com", "invalid", "bob@example.org"],
307    ///     &config,
308    /// );
309    /// assert!(results[0].is_ok());
310    /// assert!(results[1].is_err());
311    /// assert!(results[2].is_ok());
312    /// ```
313    pub fn parse_batch(inputs: &[&str], config: &Config) -> Vec<Result<Self, Error>> {
314        inputs
315            .iter()
316            .map(|input| Self::parse_with(input, config))
317            .collect()
318    }
319
320    /// Parse a batch of email addresses in parallel using rayon.
321    ///
322    /// Same semantics as [`parse_batch`](Self::parse_batch), but distributes
323    /// work across rayon's thread pool. Useful for bulk import/validation of
324    /// large lists (10K+ addresses).
325    ///
326    /// Requires the `rayon` feature.
327    ///
328    /// # Example
329    ///
330    /// ```
331    /// use structured_email_address::{EmailAddress, Config};
332    ///
333    /// let config = Config::default();
334    /// let results = EmailAddress::parse_batch_par(
335    ///     &["alice@example.com", "bob@example.org"],
336    ///     &config,
337    /// );
338    /// assert!(results.iter().all(|r| r.is_ok()));
339    /// ```
340    #[cfg(feature = "rayon")]
341    pub fn parse_batch_par(inputs: &[&str], config: &Config) -> Vec<Result<Self, Error>> {
342        use rayon::prelude::*;
343
344        inputs
345            .par_iter()
346            .map(|input| Self::parse_with(input, config))
347            .collect()
348    }
349}
350
351impl core::fmt::Display for EmailAddress {
352    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
353        let local = if needs_quoting(&self.local_part) {
354            format!("\"{}\"", escape_local_part(&self.local_part))
355        } else {
356            self.local_part.clone()
357        };
358        match &self.display_name {
359            Some(name) => write!(
360                f,
361                "\"{}\" <{}@{}>",
362                escape_display_name(name),
363                local,
364                self.domain
365            ),
366            None => write!(f, "{}@{}", local, self.domain),
367        }
368    }
369}
370
371/// Check if a local-part needs quoting for RFC 5321/5322 serialization.
372/// Returns true if the local part contains characters outside of atext.
373fn needs_quoting(local: &str) -> bool {
374    if local.is_empty() {
375        return true;
376    }
377    // Dots are only safe in valid dot-atom form (no leading/trailing/consecutive dots).
378    if local.starts_with('.') || local.ends_with('.') || local.contains("..") {
379        return true;
380    }
381    local.chars().any(|ch| {
382        !ch.is_ascii_alphanumeric()
383            && !matches!(
384                ch,
385                '!' | '#'
386                    | '$'
387                    | '%'
388                    | '&'
389                    | '\''
390                    | '*'
391                    | '+'
392                    | '-'
393                    | '/'
394                    | '='
395                    | '?'
396                    | '^'
397                    | '_'
398                    | '`'
399                    | '{'
400                    | '|'
401                    | '}'
402                    | '~'
403                    | '.'
404            )
405            && (ch as u32) < 0x80 // non-ASCII doesn't need quoting per RFC 6531
406    })
407}
408
409/// Escape a local-part for use inside quotes: backslash-escape `"` and `\`,
410/// strip CR/LF to prevent header injection (FWS is collapsed during normalization).
411fn escape_local_part(local: &str) -> String {
412    let mut escaped = String::with_capacity(local.len());
413    for ch in local.chars() {
414        match ch {
415            '"' | '\\' => {
416                escaped.push('\\');
417                escaped.push(ch);
418            }
419            '\r' | '\n' => {} // strip CRLF to prevent header injection
420            _ => escaped.push(ch),
421        }
422    }
423    escaped
424}
425
426/// Backslash-escapes `"` and `\`, and strips bare CR/LF to prevent
427/// header injection in serialized output.
428fn escape_display_name(name: &str) -> String {
429    let mut escaped = String::with_capacity(name.len());
430    for ch in name.chars() {
431        match ch {
432            '"' => {
433                escaped.push('\\');
434                escaped.push('"');
435            }
436            '\\' => {
437                escaped.push('\\');
438                escaped.push('\\');
439            }
440            '\r' | '\n' => {} // strip CRLF
441            _ => escaped.push(ch),
442        }
443    }
444    escaped
445}
446
447/// Equality is based on canonical form (`local_part` + `domain`) only.
448/// Display name, tag, and skeleton are intentionally excluded —
449/// `"John" <user@example.com>` equals `"Jane" <user@example.com>`
450/// because they route to the same mailbox.
451impl PartialEq for EmailAddress {
452    fn eq(&self, other: &Self) -> bool {
453        self.local_part == other.local_part && self.domain == other.domain
454    }
455}
456
457impl Eq for EmailAddress {}
458
459impl core::hash::Hash for EmailAddress {
460    fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
461        self.local_part.hash(state);
462        self.domain.hash(state);
463    }
464}
465
466impl core::str::FromStr for EmailAddress {
467    type Err = Error;
468
469    fn from_str(s: &str) -> Result<Self, Self::Err> {
470        Self::parse_with(s, &Config::default())
471    }
472}
473
474#[cfg(feature = "serde")]
475impl serde::Serialize for EmailAddress {
476    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
477        self.canonical().serialize(serializer)
478    }
479}
480
481#[cfg(feature = "serde")]
482impl<'de> serde::Deserialize<'de> for EmailAddress {
483    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
484        let s = String::deserialize(deserializer)?;
485        s.parse().map_err(serde::de::Error::custom)
486    }
487}
488
489#[cfg(test)]
490mod tests;