Skip to main content

use_email_id/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7/// Error returned when message identity primitives fail validation.
8#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
9pub enum MessageIdError {
10    /// The supplied value was empty after trimming.
11    Empty,
12    /// The identifier did not contain an at sign.
13    MissingAt,
14    /// The identifier contained too many at signs.
15    TooManyAtSigns,
16    /// The local part was invalid.
17    InvalidLocal,
18    /// The domain part was invalid.
19    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/// Message-ID local part.
37#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
38pub struct MessageIdLocal(String);
39
40impl MessageIdLocal {
41    /// Creates a message-id local part.
42    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    /// Returns the local-part text.
47    #[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/// Message-ID domain part.
68#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
69pub struct MessageIdDomain(String);
70
71impl MessageIdDomain {
72    /// Creates a message-id domain part.
73    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    /// Returns the domain-part text.
78    #[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/// Message-ID value rendered with angle brackets.
99#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
100pub struct MessageId {
101    local: MessageIdLocal,
102    domain: MessageIdDomain,
103}
104
105impl MessageId {
106    /// Creates a message id from separated local and domain text.
107    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    /// Returns the local part.
115    #[must_use]
116    pub const fn local(&self) -> &MessageIdLocal {
117        &self.local
118    }
119
120    /// Returns the domain part.
121    #[must_use]
122    pub const fn domain(&self) -> &MessageIdDomain {
123        &self.domain
124    }
125
126    /// Returns the inner `local@domain` text.
127    #[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/// Ordered References header values.
166#[derive(Clone, Debug, Default, Eq, PartialEq)]
167pub struct References {
168    message_ids: Vec<MessageId>,
169}
170
171impl References {
172    /// Creates an empty references collection.
173    #[must_use]
174    pub const fn new() -> Self {
175        Self {
176            message_ids: Vec::new(),
177        }
178    }
179
180    /// Adds a message id and returns the updated collection.
181    #[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    /// Appends a message id.
188    pub fn push(&mut self, message_id: MessageId) {
189        self.message_ids.push(message_id);
190    }
191
192    /// Returns all message ids.
193    #[must_use]
194    pub fn as_slice(&self) -> &[MessageId] {
195        &self.message_ids
196    }
197
198    /// Returns the number of message ids.
199    #[must_use]
200    pub fn len(&self) -> usize {
201        self.message_ids.len()
202    }
203
204    /// Returns true when no references are stored.
205    #[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/// In-Reply-To message id wrapper.
224#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
225pub struct InReplyTo(MessageId);
226
227impl InReplyTo {
228    /// Creates an In-Reply-To value.
229    #[must_use]
230    pub const fn new(message_id: MessageId) -> Self {
231        Self(message_id)
232    }
233
234    /// Returns the referenced message id.
235    #[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/// Simple thread reference path rooted at one message id.
248#[derive(Clone, Debug, Eq, PartialEq)]
249pub struct ThreadReference {
250    root: MessageId,
251    replies: Vec<MessageId>,
252}
253
254impl ThreadReference {
255    /// Creates a thread reference from the root message id.
256    #[must_use]
257    pub const fn new(root: MessageId) -> Self {
258        Self {
259            root,
260            replies: Vec::new(),
261        }
262    }
263
264    /// Adds a reply id and returns the updated thread reference.
265    #[must_use]
266    pub fn with_reply(mut self, reply: MessageId) -> Self {
267        self.replies.push(reply);
268        self
269    }
270
271    /// Returns the root id.
272    #[must_use]
273    pub const fn root(&self) -> &MessageId {
274        &self.root
275    }
276
277    /// Returns reply ids.
278    #[must_use]
279    pub fn replies(&self) -> &[MessageId] {
280        &self.replies
281    }
282
283    /// Converts the thread reference into a References value.
284    #[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}