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//! - **Configurable strictness**: Strict (5321), Standard (5322), Lax (obs-* allowed)
11//! - **Zero-copy parsing**: internal spans into the input string
12//! - **`no_std` + `alloc`**: builds for WASM and bare metal; disable the `std`
13//!   feature and enable `alloc`
14//!
15//! # Quick Start
16//!
17//! ```
18//! use structured_email_address::{EmailAddress, Config};
19//!
20//! // Simple: parse with defaults
21//! let email: EmailAddress = "user+tag@example.com".parse().unwrap();
22//! assert_eq!(email.local_part(), "user+tag");
23//! assert_eq!(email.tag(), Some("tag"));
24//! assert_eq!(email.domain(), "example.com");
25//!
26//! // Configured: Gmail normalization pipeline
27//! let config = Config::builder()
28//!     .strip_subaddress()
29//!     .dots_gmail_only()
30//!     .lowercase_all()
31//!     .build();
32//!
33//! let email = EmailAddress::parse_with("A.L.I.C.E+promo@Gmail.COM", &config).unwrap();
34//! assert_eq!(email.canonical(), "alice@gmail.com");
35//! assert_eq!(email.tag(), Some("promo"));
36//! ```
37
38#![cfg_attr(
39    not(test),
40    deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)
41)]
42#![cfg_attr(not(feature = "std"), no_std)]
43
44extern crate alloc;
45
46use alloc::format;
47use alloc::string::{String, ToString};
48use alloc::vec::Vec;
49
50mod config;
51mod error;
52mod normalize;
53mod parser;
54mod provider;
55mod validate;
56
57pub use config::{
58    AddressLiteral, CasePolicy, Config, ConfigBuilder, DomainCheck, DotPolicy, Strictness,
59    SubaddressPolicy,
60};
61pub use error::{Error, ErrorKind};
62pub use normalize::confusable_skeleton;
63pub use provider::{ProviderRegistry, ProviderRule};
64
65/// A parsed, validated, and normalized email address.
66///
67/// Immutable after construction. All accessors return borrowed data.
68#[derive(Debug, Clone)]
69pub struct EmailAddress {
70    /// Original input, exactly as supplied to the parser.
71    original: String,
72    /// Canonical local part (after normalization).
73    local_part: String,
74    /// Extracted subaddress tag, if any.
75    tag: Option<String>,
76    /// Canonical domain (IDNA-encoded, lowercased).
77    domain: String,
78    /// Unicode form of the domain (only when domain has punycode labels).
79    domain_unicode: Option<String>,
80    /// Display name, if parsed from `name-addr` format.
81    display_name: Option<String>,
82    /// Confusable skeleton, if config enabled it.
83    skeleton: Option<String>,
84    /// Whether the domain is a known freemail provider (from the registry).
85    freemail: bool,
86}
87
88impl EmailAddress {
89    /// Parse and validate with the given configuration.
90    pub fn parse_with(input: &str, config: &Config) -> Result<Self, Error> {
91        let parsed = parser::parse(
92            input,
93            config.strictness,
94            config.allow_display_name,
95            config.address_literal,
96        )?;
97
98        let normalized = normalize::normalize(&parsed, config)?;
99        validate::validate(&parsed, &normalized, config)?;
100
101        // Freemail status comes from the provider registry (built-ins + any
102        // custom rules), independent of provider-aware normalization.
103        let freemail = config
104            .providers
105            .lookup(&normalized.domain)
106            .is_some_and(|p| p.is_freemail());
107
108        Ok(Self {
109            original: parsed.input.to_string(),
110            local_part: normalized.local_part,
111            tag: normalized.tag,
112            domain: normalized.domain,
113            domain_unicode: normalized.domain_unicode,
114            display_name: normalized.display_name,
115            skeleton: normalized.skeleton,
116            freemail,
117        })
118    }
119
120    /// The canonical local part (after normalization).
121    ///
122    /// If subaddress stripping is enabled, this excludes the `+tag`.
123    /// If dot stripping is enabled, dots are removed.
124    pub fn local_part(&self) -> &str {
125        &self.local_part
126    }
127
128    /// The extracted subaddress tag, if present.
129    ///
130    /// For `user+promo@example.com`, returns `Some("promo")`.
131    /// Always extracted regardless of [`SubaddressPolicy`] — the policy only
132    /// affects whether it appears in [`canonical()`](Self::canonical).
133    pub fn tag(&self) -> Option<&str> {
134        self.tag.as_deref()
135    }
136
137    /// The canonical domain (IDNA-encoded, lowercased).
138    ///
139    /// An address literal keeps its brackets and is not IDNA-encoded. An IP
140    /// literal is still lowercased, since neither the `IPv6:` tag nor a hex
141    /// digit carries case; a [`General-address-literal`] keeps the spelling it
142    /// was given, because its body is opaque to SMTP and meaningful only to the
143    /// receiving system.
144    ///
145    /// [`General-address-literal`]: AddressLiteral::Rfc5321
146    pub fn domain(&self) -> &str {
147        &self.domain
148    }
149
150    /// The canonical domain in Unicode form.
151    ///
152    /// For internationalized domains (`münchen.de` → `xn--mnchen-3ya.de`),
153    /// returns the Unicode form of the canonical domain. For ASCII-only
154    /// domains, returns the same value as [`domain()`](Self::domain).
155    ///
156    /// # Security
157    ///
158    /// The Unicode form is intended for **display only**. It may reintroduce
159    /// [IDN homograph attacks](https://en.wikipedia.org/wiki/IDN_homograph_attack)
160    /// where visually similar characters from different scripts produce
161    /// different domain names (e.g. Cyrillic `а` vs Latin `a`).
162    ///
163    /// For security-sensitive comparisons (allow-lists, deduplication, access
164    /// control), always use [`domain()`](Self::domain) which returns the
165    /// ACE/Punycode form. If you must compare Unicode domains, apply your own
166    /// confusable-detection logic (see [`confusable_skeleton()`]).
167    ///
168    /// ```
169    /// use structured_email_address::EmailAddress;
170    ///
171    /// let email: EmailAddress = "user@münchen.de".parse().unwrap();
172    /// assert_eq!(email.domain(), "xn--mnchen-3ya.de");
173    /// assert_eq!(email.domain_unicode(), "münchen.de");
174    ///
175    /// let ascii: EmailAddress = "user@example.com".parse().unwrap();
176    /// assert_eq!(ascii.domain_unicode(), "example.com");
177    /// ```
178    pub fn domain_unicode(&self) -> &str {
179        self.domain_unicode.as_deref().unwrap_or(&self.domain)
180    }
181
182    /// The display name, if parsed from `"Name" <addr>` or `Name <addr>` format.
183    pub fn display_name(&self) -> Option<&str> {
184        self.display_name.as_deref()
185    }
186
187    /// The full canonical address: `local_part@domain`.
188    ///
189    /// If the local part contains characters that require quoting (spaces,
190    /// special chars), it is wrapped in quotes for RFC compliance.
191    pub fn canonical(&self) -> String {
192        if needs_quoting(&self.local_part) {
193            let escaped = escape_local_part(&self.local_part);
194            format!("\"{}\"@{}", escaped, self.domain)
195        } else {
196            format!("{}@{}", self.local_part, self.domain)
197        }
198    }
199
200    /// The original input, exactly as supplied to the parser (not trimmed).
201    pub fn original(&self) -> &str {
202        &self.original
203    }
204
205    /// The confusable skeleton of the local part (if config enabled it).
206    ///
207    /// Two addresses with the same skeleton + domain are visually confusable.
208    pub fn skeleton(&self) -> Option<&str> {
209        self.skeleton.as_deref()
210    }
211
212    /// Check if the domain is a known freemail provider.
213    ///
214    /// Determined from the [`ProviderRegistry`] in the [`Config`] used to parse
215    /// (built-in providers plus any registered via
216    /// [`ConfigBuilder::add_provider`]).
217    pub fn is_freemail(&self) -> bool {
218        self.freemail
219    }
220
221    /// Parse a batch of email addresses with the given configuration.
222    ///
223    /// Returns one `Result` per input, in the same order. The config is
224    /// shared across all inputs, amortizing setup cost.
225    ///
226    /// # Example
227    ///
228    /// ```
229    /// use structured_email_address::{EmailAddress, Config};
230    ///
231    /// let config = Config::default();
232    /// let results = EmailAddress::parse_batch(
233    ///     &["alice@example.com", "invalid", "bob@example.org"],
234    ///     &config,
235    /// );
236    /// assert!(results[0].is_ok());
237    /// assert!(results[1].is_err());
238    /// assert!(results[2].is_ok());
239    /// ```
240    pub fn parse_batch(inputs: &[&str], config: &Config) -> Vec<Result<Self, Error>> {
241        inputs
242            .iter()
243            .map(|input| Self::parse_with(input, config))
244            .collect()
245    }
246
247    /// Parse a batch of email addresses in parallel using rayon.
248    ///
249    /// Same semantics as [`parse_batch`](Self::parse_batch), but distributes
250    /// work across rayon's thread pool. Useful for bulk import/validation of
251    /// large lists (10K+ addresses).
252    ///
253    /// Requires the `rayon` feature.
254    ///
255    /// # Example
256    ///
257    /// ```
258    /// use structured_email_address::{EmailAddress, Config};
259    ///
260    /// let config = Config::default();
261    /// let results = EmailAddress::parse_batch_par(
262    ///     &["alice@example.com", "bob@example.org"],
263    ///     &config,
264    /// );
265    /// assert!(results.iter().all(|r| r.is_ok()));
266    /// ```
267    #[cfg(feature = "rayon")]
268    pub fn parse_batch_par(inputs: &[&str], config: &Config) -> Vec<Result<Self, Error>> {
269        use rayon::prelude::*;
270
271        inputs
272            .par_iter()
273            .map(|input| Self::parse_with(input, config))
274            .collect()
275    }
276}
277
278impl core::fmt::Display for EmailAddress {
279    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
280        let local = if needs_quoting(&self.local_part) {
281            format!("\"{}\"", escape_local_part(&self.local_part))
282        } else {
283            self.local_part.clone()
284        };
285        match &self.display_name {
286            Some(name) => write!(
287                f,
288                "\"{}\" <{}@{}>",
289                escape_display_name(name),
290                local,
291                self.domain
292            ),
293            None => write!(f, "{}@{}", local, self.domain),
294        }
295    }
296}
297
298/// Check if a local-part needs quoting for RFC 5321/5322 serialization.
299/// Returns true if the local part contains characters outside of atext.
300fn needs_quoting(local: &str) -> bool {
301    if local.is_empty() {
302        return true;
303    }
304    // Dots are only safe in valid dot-atom form (no leading/trailing/consecutive dots).
305    if local.starts_with('.') || local.ends_with('.') || local.contains("..") {
306        return true;
307    }
308    local.chars().any(|ch| {
309        !ch.is_ascii_alphanumeric()
310            && !matches!(
311                ch,
312                '!' | '#'
313                    | '$'
314                    | '%'
315                    | '&'
316                    | '\''
317                    | '*'
318                    | '+'
319                    | '-'
320                    | '/'
321                    | '='
322                    | '?'
323                    | '^'
324                    | '_'
325                    | '`'
326                    | '{'
327                    | '|'
328                    | '}'
329                    | '~'
330                    | '.'
331            )
332            && (ch as u32) < 0x80 // non-ASCII doesn't need quoting per RFC 6531
333    })
334}
335
336/// Escape a local-part for use inside quotes: backslash-escape `"` and `\`,
337/// strip CR/LF to prevent header injection (FWS is collapsed during normalization).
338fn escape_local_part(local: &str) -> String {
339    let mut escaped = String::with_capacity(local.len());
340    for ch in local.chars() {
341        match ch {
342            '"' | '\\' => {
343                escaped.push('\\');
344                escaped.push(ch);
345            }
346            '\r' | '\n' => {} // strip CRLF to prevent header injection
347            _ => escaped.push(ch),
348        }
349    }
350    escaped
351}
352
353/// Backslash-escapes `"` and `\`, and strips bare CR/LF to prevent
354/// header injection in serialized output.
355fn escape_display_name(name: &str) -> String {
356    let mut escaped = String::with_capacity(name.len());
357    for ch in name.chars() {
358        match ch {
359            '"' => {
360                escaped.push('\\');
361                escaped.push('"');
362            }
363            '\\' => {
364                escaped.push('\\');
365                escaped.push('\\');
366            }
367            '\r' | '\n' => {} // strip CRLF
368            _ => escaped.push(ch),
369        }
370    }
371    escaped
372}
373
374/// Equality is based on canonical form (`local_part` + `domain`) only.
375/// Display name, tag, and skeleton are intentionally excluded —
376/// `"John" <user@example.com>` equals `"Jane" <user@example.com>`
377/// because they route to the same mailbox.
378impl PartialEq for EmailAddress {
379    fn eq(&self, other: &Self) -> bool {
380        self.local_part == other.local_part && self.domain == other.domain
381    }
382}
383
384impl Eq for EmailAddress {}
385
386impl core::hash::Hash for EmailAddress {
387    fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
388        self.local_part.hash(state);
389        self.domain.hash(state);
390    }
391}
392
393impl core::str::FromStr for EmailAddress {
394    type Err = Error;
395
396    fn from_str(s: &str) -> Result<Self, Self::Err> {
397        Self::parse_with(s, &Config::default())
398    }
399}
400
401#[cfg(feature = "serde")]
402impl serde::Serialize for EmailAddress {
403    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
404        self.canonical().serialize(serializer)
405    }
406}
407
408#[cfg(feature = "serde")]
409impl<'de> serde::Deserialize<'de> for EmailAddress {
410    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
411        let s = String::deserialize(deserializer)?;
412        s.parse().map_err(serde::de::Error::custom)
413    }
414}
415
416#[cfg(test)]
417mod tests;