email_message/
message_id.rs1use std::fmt::Display;
2use std::str::FromStr;
3
4use crate::email::EmailAddressParseError;
5
6#[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#[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 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 if inner.starts_with('"') {
132 return Err(MessageIdParseError::ObsoleteIdLeftForm);
133 }
134
135 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 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 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 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 #[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 #[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}