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;