Skip to main content

email_message/
email.rs

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