Skip to main content

email_message/
email.rs

1use std::fmt::Display;
2use std::str::FromStr;
3
4/// A validated RFC 5322 `addr-spec` email address.
5///
6/// # Domain case-folding
7///
8/// Per RFC 5321 §2.4 the **domain** part of an address is
9/// case-insensitive while the **local part** "MUST BE treated as case
10/// sensitive." On construction this type lowercases the domain to
11/// ASCII-lowercase and preserves the local-part bytes verbatim, so
12/// `"User.Name@Example.COM"` and `"User.Name@example.com"` compare
13/// equal via the derived `PartialEq` / `Eq` / `Hash`. IP-literal
14/// domains (`[192.0.2.1]`, `[IPv6:::1]`) are not case-folded, RFC 5321
15/// §4.1.3 says address literals are case-sensitive, they keep the
16/// caller's bytes.
17///
18/// The case fold is intentional: `HashSet<EmailAddress>` and
19/// `Envelope::rcpt_to: Vec<EmailAddress>` dedup paths previously kept
20/// differently-cased spellings of the same SMTP mailbox as distinct
21/// recipients. Callers who need byte-faithful preservation of the
22/// original input should keep the source `String` separately; this
23/// type is the SMTP-equivalence value.
24#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
25#[derive(Clone, Debug, PartialEq, Eq, Hash)]
26pub struct EmailAddress {
27    value: String,
28}
29
30impl EmailAddress {
31    /// Returns the normalized address string.
32    #[must_use]
33    pub fn as_str(&self) -> &str {
34        self.value.as_str()
35    }
36}
37
38impl Display for EmailAddress {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        f.write_str(self.as_str())
41    }
42}
43
44impl AsRef<str> for EmailAddress {
45    fn as_ref(&self) -> &str {
46        self.as_str()
47    }
48}
49
50impl From<EmailAddress> for String {
51    fn from(value: EmailAddress) -> Self {
52        value.value
53    }
54}
55
56#[derive(Debug, thiserror::Error)]
57#[error(transparent)]
58pub struct EmailAddressParseError(#[from] addr_spec::ParseError);
59
60impl FromStr for EmailAddress {
61    type Err = EmailAddressParseError;
62
63    fn from_str(s: &str) -> Result<Self, Self::Err> {
64        let parsed = addr_spec::AddrSpec::from_str(s)?;
65        // RFC 5321 §2.4: domain case-insensitive, local-part case-sensitive.
66        // RFC 5321 §4.1.3: literal-form domains keep their bytes.
67        // Use `into_serialized_parts` so quoted local parts (e.g.
68        // `"john..doe"`) keep their quoting and IP-literal domains keep
69        // their `[...]` brackets, `into_parts` would strip both.
70        let is_literal = parsed.is_literal();
71        let (local, domain) = parsed.into_serialized_parts();
72        let value = if is_literal {
73            format!("{local}@{domain}")
74        } else {
75            format!("{local}@{}", domain.to_ascii_lowercase())
76        };
77        Ok(Self { value })
78    }
79}
80
81impl TryFrom<&str> for EmailAddress {
82    type Error = EmailAddressParseError;
83
84    /// Parses and validates an email from a string slice.
85    ///
86    /// ```rust
87    /// use email_message::EmailAddress;
88    ///
89    /// let email = EmailAddress::try_from("jdoe@one.test").unwrap();
90    /// assert_eq!(email.as_str(), "jdoe@one.test");
91    /// ```
92    fn try_from(value: &str) -> Result<Self, Self::Error> {
93        Self::from_str(value)
94    }
95}
96
97#[cfg(feature = "serde")]
98impl serde::Serialize for EmailAddress {
99    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
100    where
101        S: serde::Serializer,
102    {
103        serializer.serialize_str(self.as_str())
104    }
105}
106
107#[cfg(feature = "serde")]
108impl<'de> serde::Deserialize<'de> for EmailAddress {
109    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
110    where
111        D: serde::Deserializer<'de>,
112    {
113        let value = String::deserialize(deserializer)?;
114        value.parse().map_err(serde::de::Error::custom)
115    }
116}
117
118#[cfg(feature = "arbitrary")]
119impl<'a> arbitrary::Arbitrary<'a> for EmailAddress {
120    fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
121        let local = u64::arbitrary(u)?;
122        let domain = u32::arbitrary(u)?;
123        format!("user{local}@domain{domain}.test")
124            .parse()
125            .map_err(|_| arbitrary::Error::IncorrectFormat)
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::EmailAddress;
132
133    const RFC_VALID_EMAILS: &[&str] = &[
134        "jdoe@one.test",
135        "simple@example.com",
136        "very.common@example.com",
137        "disposable.style.email.with+symbol@example.com",
138        "other.email-with-hyphen@example.com",
139        "fully-qualified-domain@example.com",
140        "user.name+tag+sorting@example.com",
141        "x@example.com",
142        "example-indeed@strange-example.com",
143        "admin@mailserver1",
144        "example@s.example",
145        "\"john..doe\"@example.org",
146        "mailhost!username@example.org",
147        "user%example.com@example.org",
148    ];
149
150    const INVALID_EMAILS: &[&str] = &[
151        "plainaddress",
152        "@missing-local.org",
153        "A@b@c@example.com",
154        "john..doe@example.org",
155        "john.doe@example..org",
156        "john.doe.@example.org",
157        ".john.doe@example.org",
158    ];
159
160    #[test]
161    fn email_from_str_accepts_rfc_examples() {
162        for input in RFC_VALID_EMAILS {
163            let parsed = input.parse::<EmailAddress>();
164            assert!(parsed.is_ok(), "expected valid email: {input}");
165        }
166    }
167
168    #[test]
169    fn email_from_str_rejects_invalid_examples() {
170        for input in INVALID_EMAILS {
171            let parsed = input.parse::<EmailAddress>();
172            assert!(parsed.is_err(), "expected invalid email: {input}");
173        }
174    }
175
176    /// RFC 5321 §4.1.3 / RFC 5322 §3.4.1: `[domain-literal]` IP-literal
177    /// domains are valid `addr-spec` forms. Internal SMTP relays often
178    /// address recipients via IP literal; rejecting these surprises users
179    /// who paste an RFC-valid address into the kernel.
180    #[test]
181    fn email_from_str_accepts_ipv4_literal_domain() {
182        let parsed = "user@[192.168.1.1]".parse::<EmailAddress>();
183        assert!(parsed.is_ok(), "expected IPv4 literal to parse: {parsed:?}");
184    }
185
186    #[test]
187    fn email_from_str_accepts_ipv6_literal_domain() {
188        let parsed = "user@[IPv6:fe80::1]".parse::<EmailAddress>();
189        assert!(parsed.is_ok(), "expected IPv6 literal to parse: {parsed:?}");
190    }
191
192    /// RFC 5321 §2.4, the domain part is case-insensitive. Two
193    /// differently-cased spellings of the same mailbox compare equal
194    /// and hash to the same value. Local part stays case-sensitive.
195    #[test]
196    fn email_domain_is_case_folded_for_eq_and_hash() {
197        let a: EmailAddress = "User.Name@Example.COM".parse().unwrap();
198        let b: EmailAddress = "User.Name@example.com".parse().unwrap();
199        assert_eq!(a, b);
200        assert_eq!(a.as_str(), "User.Name@example.com");
201        assert_eq!(b.as_str(), "User.Name@example.com");
202
203        // HashSet dedup
204        use std::collections::HashSet;
205        let mut set: HashSet<EmailAddress> = HashSet::new();
206        set.insert(a);
207        assert!(set.contains(&b));
208    }
209
210    /// Local-part case is preserved verbatim per RFC 5321 §2.4.
211    #[test]
212    fn email_local_part_case_is_preserved() {
213        let upper: EmailAddress = "John.Doe@example.com".parse().unwrap();
214        let lower: EmailAddress = "john.doe@example.com".parse().unwrap();
215        assert_ne!(upper, lower);
216        assert_eq!(upper.as_str(), "John.Doe@example.com");
217        assert_eq!(lower.as_str(), "john.doe@example.com");
218    }
219
220    /// IP-literal domains are case-sensitive per RFC 5321 §4.1.3
221    /// they retain the caller's bytes.
222    #[test]
223    fn email_ipv6_literal_domain_is_not_case_folded() {
224        let parsed: EmailAddress = "user@[IPv6:Fe80::1]".parse().unwrap();
225        // The literal kept its uppercase letters (specifically `IPv6`
226        // is the addr-spec convention; the inner address bytes are
227        // also untouched by the kernel, addr-spec may normalize
228        // internally but we don't `to_ascii_lowercase` over the
229        // bracketed form).
230        assert!(parsed.as_str().contains("IPv6"));
231    }
232}