1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
9pub enum MessageIdError {
10 Empty,
12 MissingAt,
14 TooManyAtSigns,
16 InvalidLocal,
18 InvalidDomain,
20}
21
22impl fmt::Display for MessageIdError {
23 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
24 match self {
25 Self::Empty => formatter.write_str("message id value cannot be empty"),
26 Self::MissingAt => formatter.write_str("message id must contain an at sign"),
27 Self::TooManyAtSigns => formatter.write_str("message id must contain only one at sign"),
28 Self::InvalidLocal => formatter.write_str("invalid message id local part"),
29 Self::InvalidDomain => formatter.write_str("invalid message id domain part"),
30 }
31 }
32}
33
34impl Error for MessageIdError {}
35
36#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
38pub struct MessageIdLocal(String);
39
40impl MessageIdLocal {
41 pub fn new(value: impl AsRef<str>) -> Result<Self, MessageIdError> {
43 validate_local(value.as_ref()).map(|value| Self(value.to_owned()))
44 }
45
46 #[must_use]
48 pub fn as_str(&self) -> &str {
49 &self.0
50 }
51}
52
53impl fmt::Display for MessageIdLocal {
54 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
55 formatter.write_str(self.as_str())
56 }
57}
58
59impl FromStr for MessageIdLocal {
60 type Err = MessageIdError;
61
62 fn from_str(value: &str) -> Result<Self, Self::Err> {
63 Self::new(value)
64 }
65}
66
67#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
69pub struct MessageIdDomain(String);
70
71impl MessageIdDomain {
72 pub fn new(value: impl AsRef<str>) -> Result<Self, MessageIdError> {
74 validate_domain(value.as_ref()).map(|value| Self(value.to_owned()))
75 }
76
77 #[must_use]
79 pub fn as_str(&self) -> &str {
80 &self.0
81 }
82}
83
84impl fmt::Display for MessageIdDomain {
85 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
86 formatter.write_str(self.as_str())
87 }
88}
89
90impl FromStr for MessageIdDomain {
91 type Err = MessageIdError;
92
93 fn from_str(value: &str) -> Result<Self, Self::Err> {
94 Self::new(value)
95 }
96}
97
98#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
100pub struct MessageId {
101 local: MessageIdLocal,
102 domain: MessageIdDomain,
103}
104
105impl MessageId {
106 pub fn new(local: impl AsRef<str>, domain: impl AsRef<str>) -> Result<Self, MessageIdError> {
108 Ok(Self {
109 local: MessageIdLocal::new(local)?,
110 domain: MessageIdDomain::new(domain)?,
111 })
112 }
113
114 #[must_use]
116 pub const fn local(&self) -> &MessageIdLocal {
117 &self.local
118 }
119
120 #[must_use]
122 pub const fn domain(&self) -> &MessageIdDomain {
123 &self.domain
124 }
125
126 #[must_use]
128 pub fn inner(&self) -> String {
129 format!("{}@{}", self.local, self.domain)
130 }
131}
132
133impl fmt::Display for MessageId {
134 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
135 write!(formatter, "<{}@{}>", self.local, self.domain)
136 }
137}
138
139impl FromStr for MessageId {
140 type Err = MessageIdError;
141
142 fn from_str(value: &str) -> Result<Self, Self::Err> {
143 let trimmed = value.trim().trim_start_matches('<').trim_end_matches('>');
144 if trimmed.is_empty() {
145 return Err(MessageIdError::Empty);
146 }
147 let mut parts = trimmed.split('@');
148 let local = parts.next().ok_or(MessageIdError::MissingAt)?;
149 let domain = parts.next().ok_or(MessageIdError::MissingAt)?;
150 if parts.next().is_some() {
151 return Err(MessageIdError::TooManyAtSigns);
152 }
153 Self::new(local, domain)
154 }
155}
156
157impl TryFrom<&str> for MessageId {
158 type Error = MessageIdError;
159
160 fn try_from(value: &str) -> Result<Self, Self::Error> {
161 value.parse()
162 }
163}
164
165#[derive(Clone, Debug, Default, Eq, PartialEq)]
167pub struct References {
168 message_ids: Vec<MessageId>,
169}
170
171impl References {
172 #[must_use]
174 pub const fn new() -> Self {
175 Self {
176 message_ids: Vec::new(),
177 }
178 }
179
180 #[must_use]
182 pub fn with_message_id(mut self, message_id: MessageId) -> Self {
183 self.message_ids.push(message_id);
184 self
185 }
186
187 pub fn push(&mut self, message_id: MessageId) {
189 self.message_ids.push(message_id);
190 }
191
192 #[must_use]
194 pub fn as_slice(&self) -> &[MessageId] {
195 &self.message_ids
196 }
197
198 #[must_use]
200 pub fn len(&self) -> usize {
201 self.message_ids.len()
202 }
203
204 #[must_use]
206 pub fn is_empty(&self) -> bool {
207 self.message_ids.is_empty()
208 }
209}
210
211impl fmt::Display for References {
212 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
213 for (index, message_id) in self.message_ids.iter().enumerate() {
214 if index > 0 {
215 formatter.write_str(" ")?;
216 }
217 write!(formatter, "{message_id}")?;
218 }
219 Ok(())
220 }
221}
222
223#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
225pub struct InReplyTo(MessageId);
226
227impl InReplyTo {
228 #[must_use]
230 pub const fn new(message_id: MessageId) -> Self {
231 Self(message_id)
232 }
233
234 #[must_use]
236 pub const fn message_id(&self) -> &MessageId {
237 &self.0
238 }
239}
240
241impl fmt::Display for InReplyTo {
242 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
243 write!(formatter, "{}", self.0)
244 }
245}
246
247#[derive(Clone, Debug, Eq, PartialEq)]
249pub struct ThreadReference {
250 root: MessageId,
251 replies: Vec<MessageId>,
252}
253
254impl ThreadReference {
255 #[must_use]
257 pub const fn new(root: MessageId) -> Self {
258 Self {
259 root,
260 replies: Vec::new(),
261 }
262 }
263
264 #[must_use]
266 pub fn with_reply(mut self, reply: MessageId) -> Self {
267 self.replies.push(reply);
268 self
269 }
270
271 #[must_use]
273 pub const fn root(&self) -> &MessageId {
274 &self.root
275 }
276
277 #[must_use]
279 pub fn replies(&self) -> &[MessageId] {
280 &self.replies
281 }
282
283 #[must_use]
285 pub fn references(&self) -> References {
286 let mut references = References::new().with_message_id(self.root.clone());
287 for reply in &self.replies {
288 references.push(reply.clone());
289 }
290 references
291 }
292}
293
294fn validate_local(value: &str) -> Result<&str, MessageIdError> {
295 let trimmed = value.trim();
296 if trimmed.is_empty() {
297 return Err(MessageIdError::InvalidLocal);
298 }
299 if trimmed.chars().any(|character| {
300 character.is_control() || character.is_whitespace() || matches!(character, '<' | '>' | '@')
301 }) {
302 return Err(MessageIdError::InvalidLocal);
303 }
304 Ok(trimmed)
305}
306
307fn validate_domain(value: &str) -> Result<&str, MessageIdError> {
308 let trimmed = value.trim();
309 if trimmed.is_empty() {
310 return Err(MessageIdError::InvalidDomain);
311 }
312 if trimmed.starts_with('.')
313 || trimmed.ends_with('.')
314 || trimmed.contains("..")
315 || trimmed.chars().any(|character| {
316 character.is_control()
317 || character.is_whitespace()
318 || matches!(character, '<' | '>' | '@' | '_')
319 })
320 {
321 return Err(MessageIdError::InvalidDomain);
322 }
323 Ok(trimmed)
324}
325
326#[cfg(test)]
327mod tests {
328 use super::{InReplyTo, MessageId, MessageIdError, References, ThreadReference};
329
330 #[test]
331 fn parses_and_formats_message_ids() -> Result<(), MessageIdError> {
332 let message_id: MessageId = "root@example.com".parse()?;
333
334 assert_eq!(message_id.inner(), "root@example.com");
335 assert_eq!(message_id.to_string(), "<root@example.com>");
336 Ok(())
337 }
338
339 #[test]
340 fn builds_references_and_threads() -> Result<(), MessageIdError> {
341 let root: MessageId = "<root@example.com>".parse()?;
342 let reply: MessageId = "reply@example.com".parse()?;
343 let references = References::new()
344 .with_message_id(root.clone())
345 .with_message_id(reply.clone());
346 let thread = ThreadReference::new(root.clone()).with_reply(reply);
347 let in_reply_to = InReplyTo::new(root);
348
349 assert_eq!(
350 references.to_string(),
351 "<root@example.com> <reply@example.com>"
352 );
353 assert_eq!(thread.references(), references);
354 assert_eq!(in_reply_to.to_string(), "<root@example.com>");
355 Ok(())
356 }
357
358 #[test]
359 fn rejects_invalid_message_ids() {
360 assert_eq!(
361 "missing-domain@".parse::<MessageId>(),
362 Err(MessageIdError::InvalidDomain)
363 );
364 assert_eq!(
365 "missing-at".parse::<MessageId>(),
366 Err(MessageIdError::MissingAt)
367 );
368 assert_eq!(
369 "a@b@c".parse::<MessageId>(),
370 Err(MessageIdError::TooManyAtSigns)
371 );
372 }
373}