email_message/
message_id.rs1use std::fmt::Display;
7use std::str::FromStr;
8
9use crate::email::EmailAddressParseError;
10
11#[derive(Clone, Debug, PartialEq, Eq, Hash)]
13pub struct MessageId(String);
14
15impl MessageId {
16 #[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#[derive(Debug, thiserror::Error)]
77#[non_exhaustive]
78pub enum MessageIdParseError {
79 #[error("Message-ID must be enclosed in angle brackets")]
81 MissingBrackets,
82 #[error("Message-ID contains whitespace")]
84 ContainsWhitespace,
85 #[error("Message-ID is missing the local part")]
87 MissingLocal,
88 #[error("Message-ID is missing the domain part")]
90 MissingDomain,
91 #[error("Message-ID local-part or domain is malformed")]
93 #[non_exhaustive]
94 InvalidContent {
95 #[source]
97 source: EmailAddressParseError,
98 },
99 #[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 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 if inner.starts_with('"') {
148 return Err(MessageIdParseError::ObsoleteIdLeftForm);
149 }
150
151 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 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 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 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 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 #[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 #[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}