Skip to main content

use_email_address/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7/// Validation profile for address-like primitives.
8#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
9pub enum AddressValidationMode {
10    /// Conservative ASCII validation for common production addresses.
11    #[default]
12    Practical,
13    /// ASCII-only validation with conservative domain-label rules.
14    StrictAscii,
15    /// Allows non-ASCII text while still rejecting control characters and obvious separators.
16    Internationalized,
17}
18
19/// Error returned when an email address primitive fails validation.
20#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
21pub enum AddressValidationError {
22    /// The supplied value was empty after trimming.
23    Empty,
24    /// The address did not contain an at sign.
25    MissingAt,
26    /// The address contained more than one at sign.
27    TooManyAtSigns,
28    /// The local part was empty.
29    EmptyLocalPart,
30    /// The domain part was empty.
31    EmptyDomain,
32    /// The local part used syntax rejected by this crate's conservative rules.
33    InvalidLocalPart,
34    /// The domain part used syntax rejected by this crate's conservative rules.
35    InvalidDomain,
36    /// The display name used syntax rejected by this crate's conservative rules.
37    InvalidDisplayName,
38    /// The selected validation mode requires ASCII text.
39    NonAscii,
40}
41
42impl fmt::Display for AddressValidationError {
43    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
44        match self {
45            Self::Empty => formatter.write_str("email address value cannot be empty"),
46            Self::MissingAt => formatter.write_str("email address must contain an at sign"),
47            Self::TooManyAtSigns => {
48                formatter.write_str("email address must contain only one at sign")
49            }
50            Self::EmptyLocalPart => formatter.write_str("email local part cannot be empty"),
51            Self::EmptyDomain => formatter.write_str("email domain part cannot be empty"),
52            Self::InvalidLocalPart => formatter.write_str("invalid email local part"),
53            Self::InvalidDomain => formatter.write_str("invalid email domain part"),
54            Self::InvalidDisplayName => formatter.write_str("invalid email display name"),
55            Self::NonAscii => {
56                formatter.write_str("email value must be ASCII for this validation mode")
57            }
58        }
59    }
60}
61
62impl Error for AddressValidationError {}
63
64/// Email local-part text.
65#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
66pub struct LocalPart(String);
67
68impl LocalPart {
69    /// Creates a local part using practical validation.
70    pub fn new(value: impl AsRef<str>) -> Result<Self, AddressValidationError> {
71        Self::new_with_mode(value, AddressValidationMode::Practical)
72    }
73
74    /// Creates a local part using the requested validation mode.
75    pub fn new_with_mode(
76        value: impl AsRef<str>,
77        mode: AddressValidationMode,
78    ) -> Result<Self, AddressValidationError> {
79        validate_local_part(value.as_ref(), mode).map(|value| Self(value.to_owned()))
80    }
81
82    /// Returns the local-part text.
83    #[must_use]
84    pub fn as_str(&self) -> &str {
85        &self.0
86    }
87}
88
89impl AsRef<str> for LocalPart {
90    fn as_ref(&self) -> &str {
91        self.as_str()
92    }
93}
94
95impl fmt::Display for LocalPart {
96    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
97        formatter.write_str(self.as_str())
98    }
99}
100
101impl FromStr for LocalPart {
102    type Err = AddressValidationError;
103
104    fn from_str(value: &str) -> Result<Self, Self::Err> {
105        Self::new(value)
106    }
107}
108
109impl TryFrom<&str> for LocalPart {
110    type Error = AddressValidationError;
111
112    fn try_from(value: &str) -> Result<Self, Self::Error> {
113        Self::new(value)
114    }
115}
116
117/// Email domain-part text.
118#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
119pub struct DomainPart(String);
120
121impl DomainPart {
122    /// Creates a domain part using practical validation.
123    pub fn new(value: impl AsRef<str>) -> Result<Self, AddressValidationError> {
124        Self::new_with_mode(value, AddressValidationMode::Practical)
125    }
126
127    /// Creates a domain part using the requested validation mode.
128    pub fn new_with_mode(
129        value: impl AsRef<str>,
130        mode: AddressValidationMode,
131    ) -> Result<Self, AddressValidationError> {
132        validate_domain_part(value.as_ref(), mode).map(|value| Self(value.to_owned()))
133    }
134
135    /// Returns the domain-part text.
136    #[must_use]
137    pub fn as_str(&self) -> &str {
138        &self.0
139    }
140}
141
142impl AsRef<str> for DomainPart {
143    fn as_ref(&self) -> &str {
144        self.as_str()
145    }
146}
147
148impl fmt::Display for DomainPart {
149    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
150        formatter.write_str(self.as_str())
151    }
152}
153
154impl FromStr for DomainPart {
155    type Err = AddressValidationError;
156
157    fn from_str(value: &str) -> Result<Self, Self::Err> {
158        Self::new(value)
159    }
160}
161
162impl TryFrom<&str> for DomainPart {
163    type Error = AddressValidationError;
164
165    fn try_from(value: &str) -> Result<Self, Self::Error> {
166        Self::new(value)
167    }
168}
169
170/// Validated email address text split into local and domain parts.
171#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
172pub struct EmailAddress {
173    local_part: LocalPart,
174    domain_part: DomainPart,
175}
176
177impl EmailAddress {
178    /// Creates an address using practical validation.
179    pub fn new(value: impl AsRef<str>) -> Result<Self, AddressValidationError> {
180        Self::new_with_mode(value, AddressValidationMode::Practical)
181    }
182
183    /// Creates an address using the requested validation mode.
184    pub fn new_with_mode(
185        value: impl AsRef<str>,
186        mode: AddressValidationMode,
187    ) -> Result<Self, AddressValidationError> {
188        let trimmed = value.as_ref().trim();
189        if trimmed.is_empty() {
190            return Err(AddressValidationError::Empty);
191        }
192        let mut parts = trimmed.split('@');
193        let local = parts.next().ok_or(AddressValidationError::MissingAt)?;
194        let domain = parts.next().ok_or(AddressValidationError::MissingAt)?;
195        if parts.next().is_some() {
196            return Err(AddressValidationError::TooManyAtSigns);
197        }
198        Self::from_parts_with_mode(local, domain, mode)
199    }
200
201    /// Creates an address from already separated local and domain text.
202    pub fn from_parts(
203        local_part: impl AsRef<str>,
204        domain_part: impl AsRef<str>,
205    ) -> Result<Self, AddressValidationError> {
206        Self::from_parts_with_mode(local_part, domain_part, AddressValidationMode::Practical)
207    }
208
209    /// Creates an address from separated parts using the requested validation mode.
210    pub fn from_parts_with_mode(
211        local_part: impl AsRef<str>,
212        domain_part: impl AsRef<str>,
213        mode: AddressValidationMode,
214    ) -> Result<Self, AddressValidationError> {
215        Ok(Self {
216            local_part: LocalPart::new_with_mode(local_part, mode)?,
217            domain_part: DomainPart::new_with_mode(domain_part, mode)?,
218        })
219    }
220
221    /// Returns the local part.
222    #[must_use]
223    pub const fn local_part(&self) -> &LocalPart {
224        &self.local_part
225    }
226
227    /// Returns the domain part.
228    #[must_use]
229    pub const fn domain_part(&self) -> &DomainPart {
230        &self.domain_part
231    }
232
233    /// Returns an owned address string.
234    #[must_use]
235    pub fn into_string(self) -> String {
236        self.to_string()
237    }
238}
239
240impl fmt::Display for EmailAddress {
241    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
242        write!(formatter, "{}@{}", self.local_part, self.domain_part)
243    }
244}
245
246impl FromStr for EmailAddress {
247    type Err = AddressValidationError;
248
249    fn from_str(value: &str) -> Result<Self, Self::Err> {
250        Self::new(value)
251    }
252}
253
254impl TryFrom<&str> for EmailAddress {
255    type Error = AddressValidationError;
256
257    fn try_from(value: &str) -> Result<Self, Self::Error> {
258        Self::new(value)
259    }
260}
261
262/// Human-readable display name for a mailbox.
263#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
264pub struct DisplayName(String);
265
266impl DisplayName {
267    /// Creates a display name.
268    pub fn new(value: impl AsRef<str>) -> Result<Self, AddressValidationError> {
269        validate_display_name(value.as_ref()).map(|value| Self(value.to_owned()))
270    }
271
272    /// Returns the display-name text.
273    #[must_use]
274    pub fn as_str(&self) -> &str {
275        &self.0
276    }
277}
278
279impl AsRef<str> for DisplayName {
280    fn as_ref(&self) -> &str {
281        self.as_str()
282    }
283}
284
285impl fmt::Display for DisplayName {
286    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
287        formatter.write_str(self.as_str())
288    }
289}
290
291impl FromStr for DisplayName {
292    type Err = AddressValidationError;
293
294    fn from_str(value: &str) -> Result<Self, Self::Err> {
295        Self::new(value)
296    }
297}
298
299/// A visible mailbox: optional display name plus email address.
300#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
301pub struct Mailbox {
302    display_name: Option<DisplayName>,
303    address: EmailAddress,
304}
305
306impl Mailbox {
307    /// Creates a mailbox from optional display name text and address text.
308    pub fn new(
309        display_name: Option<&str>,
310        address: impl AsRef<str>,
311    ) -> Result<Self, AddressValidationError> {
312        Ok(Self {
313            display_name: display_name.map(DisplayName::new).transpose()?,
314            address: EmailAddress::new(address)?,
315        })
316    }
317
318    /// Creates a mailbox from an already validated address.
319    #[must_use]
320    pub const fn from_address(address: EmailAddress) -> Self {
321        Self {
322            display_name: None,
323            address,
324        }
325    }
326
327    /// Adds a display name to the mailbox.
328    pub fn with_display_name(
329        mut self,
330        display_name: impl AsRef<str>,
331    ) -> Result<Self, AddressValidationError> {
332        self.display_name = Some(DisplayName::new(display_name)?);
333        Ok(self)
334    }
335
336    /// Returns the display name, when present.
337    #[must_use]
338    pub const fn display_name(&self) -> Option<&DisplayName> {
339        self.display_name.as_ref()
340    }
341
342    /// Returns the mailbox address.
343    #[must_use]
344    pub const fn address(&self) -> &EmailAddress {
345        &self.address
346    }
347}
348
349impl fmt::Display for Mailbox {
350    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
351        if let Some(display_name) = &self.display_name {
352            write!(
353                formatter,
354                "\"{}\" <{}>",
355                escape_display_name(display_name.as_str()),
356                self.address
357            )
358        } else {
359            write!(formatter, "{}", self.address)
360        }
361    }
362}
363
364impl FromStr for Mailbox {
365    type Err = AddressValidationError;
366
367    fn from_str(value: &str) -> Result<Self, Self::Err> {
368        let trimmed = value.trim();
369        if trimmed.is_empty() {
370            return Err(AddressValidationError::Empty);
371        }
372        if let Some(start) = trimmed.rfind('<') {
373            let end = trimmed
374                .rfind('>')
375                .ok_or(AddressValidationError::InvalidLocalPart)?;
376            if end <= start {
377                return Err(AddressValidationError::InvalidLocalPart);
378            }
379            let display = trimmed[..start].trim().trim_matches('"').trim();
380            let address = trimmed[start + 1..end].trim();
381            let display_name = if display.is_empty() {
382                None
383            } else {
384                Some(display)
385            };
386            Self::new(display_name, address)
387        } else {
388            Self::new(None, trimmed)
389        }
390    }
391}
392
393/// A comma-rendered list of visible mailboxes.
394#[derive(Clone, Debug, Default, Eq, PartialEq)]
395pub struct MailboxList {
396    mailboxes: Vec<Mailbox>,
397}
398
399impl MailboxList {
400    /// Creates an empty mailbox list.
401    #[must_use]
402    pub const fn new() -> Self {
403        Self {
404            mailboxes: Vec::new(),
405        }
406    }
407
408    /// Adds a mailbox and returns the updated list.
409    #[must_use]
410    pub fn with_mailbox(mut self, mailbox: Mailbox) -> Self {
411        self.mailboxes.push(mailbox);
412        self
413    }
414
415    /// Appends a mailbox.
416    pub fn push(&mut self, mailbox: Mailbox) {
417        self.mailboxes.push(mailbox);
418    }
419
420    /// Returns the mailboxes.
421    #[must_use]
422    pub fn as_slice(&self) -> &[Mailbox] {
423        &self.mailboxes
424    }
425
426    /// Returns the number of mailboxes.
427    #[must_use]
428    pub fn len(&self) -> usize {
429        self.mailboxes.len()
430    }
431
432    /// Returns true when the list is empty.
433    #[must_use]
434    pub fn is_empty(&self) -> bool {
435        self.mailboxes.is_empty()
436    }
437}
438
439impl fmt::Display for MailboxList {
440    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
441        for (index, mailbox) in self.mailboxes.iter().enumerate() {
442            if index > 0 {
443                formatter.write_str(", ")?;
444            }
445            write!(formatter, "{mailbox}")?;
446        }
447        Ok(())
448    }
449}
450
451/// A named group of visible mailboxes.
452#[derive(Clone, Debug, Eq, PartialEq)]
453pub struct AddressGroup {
454    name: DisplayName,
455    members: MailboxList,
456}
457
458impl AddressGroup {
459    /// Creates an address group.
460    pub fn new(
461        name: impl AsRef<str>,
462        members: MailboxList,
463    ) -> Result<Self, AddressValidationError> {
464        Ok(Self {
465            name: DisplayName::new(name)?,
466            members,
467        })
468    }
469
470    /// Returns the group name.
471    #[must_use]
472    pub const fn name(&self) -> &DisplayName {
473        &self.name
474    }
475
476    /// Returns the group members.
477    #[must_use]
478    pub const fn members(&self) -> &MailboxList {
479        &self.members
480    }
481}
482
483impl fmt::Display for AddressGroup {
484    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
485        write!(formatter, "{}: {};", self.name, self.members)
486    }
487}
488
489fn validate_local_part(
490    value: &str,
491    mode: AddressValidationMode,
492) -> Result<&str, AddressValidationError> {
493    let trimmed = value.trim();
494    if trimmed.is_empty() {
495        return Err(AddressValidationError::EmptyLocalPart);
496    }
497    if mode != AddressValidationMode::Internationalized && !trimmed.is_ascii() {
498        return Err(AddressValidationError::NonAscii);
499    }
500    if trimmed.starts_with('.') || trimmed.ends_with('.') || trimmed.contains("..") {
501        return Err(AddressValidationError::InvalidLocalPart);
502    }
503    if trimmed.chars().any(|character| {
504        character.is_control()
505            || character.is_whitespace()
506            || matches!(character, '@' | '<' | '>' | ',' | ';')
507            || (mode != AddressValidationMode::Internationalized && !is_local_ascii(character))
508    }) {
509        return Err(AddressValidationError::InvalidLocalPart);
510    }
511    Ok(trimmed)
512}
513
514fn validate_domain_part(
515    value: &str,
516    mode: AddressValidationMode,
517) -> Result<&str, AddressValidationError> {
518    let trimmed = value.trim().trim_end_matches('.');
519    if trimmed.is_empty() {
520        return Err(AddressValidationError::EmptyDomain);
521    }
522    if mode != AddressValidationMode::Internationalized && !trimmed.is_ascii() {
523        return Err(AddressValidationError::NonAscii);
524    }
525    if trimmed.starts_with('.') || trimmed.contains("..") {
526        return Err(AddressValidationError::InvalidDomain);
527    }
528    for label in trimmed.split('.') {
529        if label.is_empty() || label.starts_with('-') || label.ends_with('-') {
530            return Err(AddressValidationError::InvalidDomain);
531        }
532        if label.chars().any(|character| {
533            character.is_control()
534                || character.is_whitespace()
535                || matches!(character, '@' | '<' | '>' | ',' | ';' | '_')
536                || (mode != AddressValidationMode::Internationalized && !is_domain_ascii(character))
537        }) {
538            return Err(AddressValidationError::InvalidDomain);
539        }
540    }
541    Ok(trimmed)
542}
543
544fn validate_display_name(value: &str) -> Result<&str, AddressValidationError> {
545    let trimmed = value.trim();
546    if trimmed.is_empty() {
547        return Err(AddressValidationError::Empty);
548    }
549    if trimmed
550        .chars()
551        .any(|character| character.is_control() || matches!(character, '<' | '>' | '\r' | '\n'))
552    {
553        return Err(AddressValidationError::InvalidDisplayName);
554    }
555    Ok(trimmed)
556}
557
558fn is_local_ascii(character: char) -> bool {
559    character.is_ascii_alphanumeric()
560        || matches!(
561            character,
562            '!' | '#'
563                | '$'
564                | '%'
565                | '&'
566                | '\''
567                | '*'
568                | '+'
569                | '-'
570                | '/'
571                | '='
572                | '?'
573                | '^'
574                | '_'
575                | '`'
576                | '{'
577                | '|'
578                | '}'
579                | '~'
580                | '.'
581        )
582}
583
584fn is_domain_ascii(character: char) -> bool {
585    character.is_ascii_alphanumeric() || matches!(character, '-' | '.')
586}
587
588fn escape_display_name(value: &str) -> String {
589    let mut escaped = String::new();
590    for character in value.chars() {
591        if matches!(character, '\\' | '"') {
592            escaped.push('\\');
593        }
594        escaped.push(character);
595    }
596    escaped
597}
598
599#[cfg(test)]
600mod tests {
601    use super::{
602        AddressGroup, AddressValidationError, AddressValidationMode, EmailAddress, Mailbox,
603        MailboxList,
604    };
605
606    #[test]
607    fn parses_practical_addresses() -> Result<(), AddressValidationError> {
608        let address: EmailAddress = "jane.doe+notes@example.com".parse()?;
609
610        assert_eq!(address.local_part().as_str(), "jane.doe+notes");
611        assert_eq!(address.domain_part().as_str(), "example.com");
612        assert_eq!(address.to_string(), "jane.doe+notes@example.com");
613        Ok(())
614    }
615
616    #[test]
617    fn validation_modes_are_explicit() {
618        assert_eq!(
619            EmailAddress::new_with_mode("jane@exämple.test", AddressValidationMode::StrictAscii),
620            Err(AddressValidationError::NonAscii)
621        );
622        assert!(
623            EmailAddress::new_with_mode(
624                "jane@exämple.test",
625                AddressValidationMode::Internationalized
626            )
627            .is_ok()
628        );
629    }
630
631    #[test]
632    fn renders_mailbox_lists_and_groups() -> Result<(), AddressValidationError> {
633        let jane = Mailbox::new(Some("Jane Doe"), "jane@example.com")?;
634        let ada: Mailbox = "Ada <ada@example.com>".parse()?;
635        let list = MailboxList::new().with_mailbox(jane).with_mailbox(ada);
636        let group = AddressGroup::new("Team", list)?;
637
638        assert_eq!(
639            group.to_string(),
640            "Team: \"Jane Doe\" <jane@example.com>, \"Ada\" <ada@example.com>;"
641        );
642        Ok(())
643    }
644
645    #[test]
646    fn rejects_obvious_invalid_addresses() {
647        assert_eq!(
648            EmailAddress::new("jane.example.com"),
649            Err(AddressValidationError::MissingAt)
650        );
651        assert_eq!(
652            EmailAddress::new("jane@@example.com"),
653            Err(AddressValidationError::TooManyAtSigns)
654        );
655        assert_eq!(
656            EmailAddress::new("jane@-example.com"),
657            Err(AddressValidationError::InvalidDomain)
658        );
659    }
660}