Skip to main content

email_message/
message_id.rs

1use std::fmt::Display;
2use std::str::FromStr;
3
4use crate::email::EmailAddressParseError;
5
6/// A validated RFC 5322 `Message-ID` field value.
7#[derive(Clone, Debug, PartialEq, Eq, Hash)]
8pub struct MessageId(String);
9
10impl MessageId {
11    #[must_use]
12    pub fn as_str(&self) -> &str {
13        self.0.as_str()
14    }
15}
16
17impl Display for MessageId {
18    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
19        f.write_str(self.as_str())
20    }
21}
22
23#[cfg(feature = "schemars")]
24impl schemars::JsonSchema for MessageId {
25    fn inline_schema() -> bool {
26        true
27    }
28
29    fn schema_name() -> std::borrow::Cow<'static, str> {
30        "MessageId".into()
31    }
32
33    fn schema_id() -> std::borrow::Cow<'static, str> {
34        concat!(module_path!(), "::MessageId").into()
35    }
36
37    fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
38        schemars::json_schema!({
39            "type": "string",
40            "description": "RFC 5322 Message-ID field value, including angle brackets"
41        })
42    }
43}
44
45/// Reasons a string cannot be parsed as an RFC 5322 `Message-ID`.
46///
47/// ```rust
48/// use email_message::{MessageId, MessageIdParseError};
49///
50/// // Brackets are mandatory.
51/// assert_eq!(
52///     "abc@example.com".parse::<MessageId>().unwrap_err(),
53///     MessageIdParseError::MissingBrackets,
54/// );
55///
56/// // Local part validates against the addr-spec dot-atom grammar:
57/// // a leading dot is illegal.
58/// assert!(matches!(
59///     "<.bad@example.com>".parse::<MessageId>().unwrap_err(),
60///     MessageIdParseError::InvalidContent { .. },
61/// ));
62///
63/// // A well-formed Message-ID round-trips its bracketed form.
64/// let parsed = "<good@example.com>".parse::<MessageId>().unwrap();
65/// assert_eq!(parsed.as_str(), "<good@example.com>");
66/// ```
67#[derive(Debug, thiserror::Error)]
68#[non_exhaustive]
69pub enum MessageIdParseError {
70    #[error("Message-ID must be enclosed in angle brackets")]
71    MissingBrackets,
72    #[error("Message-ID contains whitespace")]
73    ContainsWhitespace,
74    #[error("Message-ID is missing the local part")]
75    MissingLocal,
76    #[error("Message-ID is missing the domain part")]
77    MissingDomain,
78    #[error("Message-ID local-part or domain is malformed")]
79    #[non_exhaustive]
80    InvalidContent {
81        #[source]
82        source: EmailAddressParseError,
83    },
84    #[error(
85        "Message-ID `id-left` uses the obsolete quoted-string form; the kernel commits to RFC 5322 dot-atom-text only"
86    )]
87    ObsoleteIdLeftForm,
88}
89
90impl PartialEq for MessageIdParseError {
91    fn eq(&self, other: &Self) -> bool {
92        // Pragmatic equality: variants compare by tag, ignoring the
93        // boxed `source` chain on `InvalidContent`. Sufficient for tests
94        // and avoids forcing `Eq` on the `addr_spec::ParseError` we
95        // transitively carry.
96        matches!(
97            (self, other),
98            (Self::MissingBrackets, Self::MissingBrackets)
99                | (Self::ContainsWhitespace, Self::ContainsWhitespace)
100                | (Self::MissingLocal, Self::MissingLocal)
101                | (Self::MissingDomain, Self::MissingDomain)
102                | (Self::InvalidContent { .. }, Self::InvalidContent { .. })
103                | (Self::ObsoleteIdLeftForm, Self::ObsoleteIdLeftForm)
104        )
105    }
106}
107
108impl Eq for MessageIdParseError {}
109
110impl FromStr for MessageId {
111    type Err = MessageIdParseError;
112
113    fn from_str(s: &str) -> Result<Self, Self::Err> {
114        let value = s.trim();
115        if !(value.starts_with('<') && value.ends_with('>') && value.len() >= 2) {
116            return Err(MessageIdParseError::MissingBrackets);
117        }
118
119        if value.chars().any(char::is_whitespace) {
120            return Err(MessageIdParseError::ContainsWhitespace);
121        }
122
123        let inner = &value[1..value.len() - 1];
124
125        // RFC 5322 §3.6.4 `id-left = dot-atom-text / obs-id-left`. The
126        // kernel commits to dot-atom-text; `obs-id-left` (which permits
127        // `quoted-string`) is the obsolete branch we deliberately reject
128        // so equality between canonical and quoted-string spellings
129        // doesn't drift (the type derives `Eq`/`Hash` over the stored
130        // bytes).
131        if inner.starts_with('"') {
132            return Err(MessageIdParseError::ObsoleteIdLeftForm);
133        }
134
135        // Empty local / empty domain are caught by addr-spec's normalize
136        // (it rejects `@example.com`, `abc@`, and `abc` for missing-`@`).
137        // We still distinguish the missing-local / missing-domain /
138        // no-`@` cases for ergonomic error messages: addr-spec returns a
139        // generic parse error for all three, but the kernel can be more
140        // specific on the obvious shape problems.
141        if let Some((local, domain)) = inner.split_once('@') {
142            if local.is_empty() {
143                return Err(MessageIdParseError::MissingLocal);
144            }
145            if domain.is_empty() {
146                return Err(MessageIdParseError::MissingDomain);
147            }
148        } else {
149            return Err(MessageIdParseError::MissingDomain);
150        }
151
152        // RFC 5321 §2.4: domain case-insensitive, local-part case-sensitive.
153        // RFC 5321 §4.1.3: literal-form domains keep their bytes. Mirrors the
154        // case-folding `EmailAddress::from_str` performs so two MessageIds that are
155        // RFC 5321-equivalent compare equal under derived `Eq`/`Hash`.
156        let parsed = addr_spec::AddrSpec::from_str(inner).map_err(|error| {
157            MessageIdParseError::InvalidContent {
158                source: EmailAddressParseError::from(error),
159            }
160        })?;
161        let is_literal = parsed.is_literal();
162        let (local, domain) = parsed.into_serialized_parts();
163        let normalized = if is_literal {
164            format!("<{local}@{domain}>")
165        } else {
166            format!("<{local}@{}>", domain.to_ascii_lowercase())
167        };
168
169        Ok(Self(normalized))
170    }
171}
172
173impl TryFrom<&str> for MessageId {
174    type Error = MessageIdParseError;
175
176    fn try_from(value: &str) -> Result<Self, Self::Error> {
177        Self::from_str(value)
178    }
179}
180
181impl From<MessageId> for String {
182    fn from(value: MessageId) -> Self {
183        value.0
184    }
185}
186
187#[cfg(feature = "serde")]
188impl serde::Serialize for MessageId {
189    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
190    where
191        S: serde::Serializer,
192    {
193        serializer.serialize_str(self.as_str())
194    }
195}
196
197#[cfg(feature = "serde")]
198impl<'de> serde::Deserialize<'de> for MessageId {
199    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
200    where
201        D: serde::Deserializer<'de>,
202    {
203        let value = String::deserialize(deserializer)?;
204        value.parse().map_err(serde::de::Error::custom)
205    }
206}
207
208#[cfg(feature = "arbitrary")]
209impl<'a> arbitrary::Arbitrary<'a> for MessageId {
210    fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
211        let local = u64::arbitrary(u)?;
212        let domain = u32::arbitrary(u)?;
213        Ok(Self(format!("<{local}@{domain}.test>")))
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::{MessageId, MessageIdParseError};
220
221    #[test]
222    fn message_id_from_str_accepts_valid_values() {
223        let parsed = "<abc@example.com>".parse::<MessageId>();
224        assert!(parsed.is_ok(), "expected valid message id");
225    }
226
227    #[test]
228    fn message_id_from_str_rejects_missing_brackets() {
229        let parsed = "abc@example.com".parse::<MessageId>();
230        assert_eq!(parsed.unwrap_err(), MessageIdParseError::MissingBrackets);
231    }
232
233    #[test]
234    fn message_id_from_str_rejects_missing_at() {
235        // `<abc>` has no `@`; the `id-right` (domain) portion is
236        // structurally absent.
237        let parsed = "<abc>".parse::<MessageId>();
238        assert_eq!(parsed.unwrap_err(), MessageIdParseError::MissingDomain);
239    }
240
241    #[test]
242    fn message_id_from_str_rejects_whitespace() {
243        let parsed = "<abc @example.com>".parse::<MessageId>();
244        assert_eq!(parsed.unwrap_err(), MessageIdParseError::ContainsWhitespace);
245    }
246
247    #[test]
248    fn message_id_from_str_rejects_empty_local_part() {
249        let parsed = "<@example.com>".parse::<MessageId>();
250        assert_eq!(parsed.unwrap_err(), MessageIdParseError::MissingLocal);
251    }
252
253    #[test]
254    fn message_id_from_str_rejects_empty_domain() {
255        let parsed = "<abc@>".parse::<MessageId>();
256        assert_eq!(parsed.unwrap_err(), MessageIdParseError::MissingDomain);
257    }
258
259    #[test]
260    fn message_id_from_str_rejects_dot_atom_violations() {
261        // Leading dot, double dot, trailing dot in the local-part are
262        // dot-atom violations; previously slipped through.
263        for input in [
264            "<.bad@example.com>",
265            "<a..b@example.com>",
266            "<a.@example.com>",
267        ] {
268            let parsed = input.parse::<MessageId>();
269            assert!(
270                matches!(parsed, Err(MessageIdParseError::InvalidContent { .. })),
271                "expected InvalidContent for {input}, got {parsed:?}"
272            );
273        }
274    }
275
276    /// RFC 5322 §3.6.4 `id-left = dot-atom-text / obs-id-left`. The kernel
277    /// commits to dot-atom-text only; `obs-id-left` (which permits
278    /// `quoted-string`) is the obsolete branch. Accepting quoted-string
279    /// here would mean two semantically equal IDs (canonical vs
280    /// quoted-string spelling) hash and compare unequal because
281    /// `MessageId` derives `Eq`/`Hash` over the stored bytes.
282    #[test]
283    fn message_id_from_str_rejects_quoted_string_id_left() {
284        let parsed = "<\"weird\"@example.com>".parse::<MessageId>();
285        assert_eq!(parsed.unwrap_err(), MessageIdParseError::ObsoleteIdLeftForm);
286    }
287
288    #[test]
289    fn message_id_from_str_rejects_quoted_at_in_local_part() {
290        let parsed = "<\"a@b\"@example.com>".parse::<MessageId>();
291        assert_eq!(parsed.unwrap_err(), MessageIdParseError::ObsoleteIdLeftForm);
292    }
293
294    /// Two RFC 5321-equivalent message ids that differ only in domain casing
295    /// must compare equal and hash identically. Mirrors `EmailAddress`'s case-folding
296    /// guarantee.
297    #[test]
298    fn message_id_from_str_case_folds_domain() {
299        use std::collections::hash_map::DefaultHasher;
300        use std::hash::{Hash, Hasher};
301
302        let upper = "<foo@Example.COM>"
303            .parse::<MessageId>()
304            .expect("upper-case domain should parse");
305        let lower = "<foo@example.com>"
306            .parse::<MessageId>()
307            .expect("lower-case domain should parse");
308
309        assert_eq!(upper, lower);
310        assert_eq!(upper.as_str(), "<foo@example.com>");
311
312        let mut h_upper = DefaultHasher::new();
313        upper.hash(&mut h_upper);
314        let mut h_lower = DefaultHasher::new();
315        lower.hash(&mut h_lower);
316        assert_eq!(h_upper.finish(), h_lower.finish());
317    }
318}