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