Skip to main content

email_message/
message_id.rs

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