Skip to main content

mailsis_utils/
mime.rs

1//! MIME validation and raw header parsing.
2//!
3//! Incoming messages need their headers inspected at several points in the
4//! pipeline, to check for a `MIME-Version` header per
5//! [RFC 2045](https://www.rfc-editor.org/rfc/rfc2045), to extract
6//! structured key-value pairs, or to split headers from the body.
7
8use std::{collections::HashMap, error::Error};
9
10/// Checks whether a raw email contains a `MIME-Version:` header,
11/// indicating it is a valid MIME message per RFC 2045.
12///
13/// Only the header section (lines before the first blank line) is inspected.
14/// A `MIME-Version:` header appearing in the content (after the blank
15/// line separator) is not considered valid.
16///
17/// # Examples
18///
19/// A message with a `MIME-Version` header is valid:
20///
21/// ```rust
22/// assert!(mailsis_utils::is_mime_valid(
23///     "MIME-Version: 1.0\r\nContent-Type: text/plain\r\n\r\nBody"
24/// ));
25/// ```
26///
27/// A message without a `MIME-Version` header is not valid:
28///
29/// ```rust
30/// assert!(!mailsis_utils::is_mime_valid("Subject: Hello\r\n\r\nBody"));
31/// ```
32///
33/// `MIME-Version` in the body (after the blank line) does not count:
34///
35/// ```rust
36/// assert!(!mailsis_utils::is_mime_valid(
37///     "Subject: Hello\r\n\r\nMIME-Version: 1.0"
38/// ));
39/// ```
40pub fn is_mime_valid(raw: &str) -> bool {
41    for line in raw.lines() {
42        if line.trim().is_empty() {
43            break;
44        }
45        if line.trim().starts_with("MIME-Version:") {
46            return true;
47        }
48    }
49    false
50}
51
52/// Parses the headers of a MIME email, returning a hashmap of the headers.
53///
54/// The parsing is done by splitting the raw email on the first empty line,
55/// and splitting each line on the colon, separating the key and value.
56///
57/// Aligned with the RFC 2045, the headers are case-insensitive.
58///
59/// # Examples
60///
61/// ```rust
62/// let expected = [("From", "test@example.com"), ("To", "test@example.com")]
63///     .into_iter()
64///     .map(|(k, v)| (k.to_string(), v.to_string()))
65///     .collect::<std::collections::HashMap<_, _>>();
66/// let headers = mailsis_utils::parse_mime_headers("From: test@example.com\r\nTo: test@example.com\r\n").unwrap();
67/// assert_eq!(headers, expected);
68/// ```
69///
70/// ```rust
71/// let expected = [("From", "test@example.com"), ("To", "test@example.com")]
72///     .into_iter()
73///     .map(|(k, v)| (k.to_string(), v.to_string()))
74///     .collect::<std::collections::HashMap<_, _>>();
75/// let headers = mailsis_utils::parse_mime_headers("From: test@example.com\r\nTo: test@example.com\r\n\r\nBody of the email").unwrap();
76/// assert_eq!(headers, expected);
77/// ```
78pub fn parse_mime_headers(raw: &str) -> Result<HashMap<String, String>, Box<dyn Error>> {
79    let mut headers = HashMap::new();
80    for line in raw.lines() {
81        if line.trim().is_empty() {
82            break;
83        }
84        if let Some((key, value)) = line.split_once(':') {
85            headers.insert(key.trim().to_string(), value.trim().to_string());
86        }
87    }
88    Ok(headers)
89}
90
91/// Parses headers from a raw email, returning an ordered list of headers
92/// and a reference to the content after the blank-line separator.
93///
94/// Headers are preserved in their original order with case-preserved keys
95/// and trimmed values. This supports duplicate headers (e.g. `Received`).
96///
97/// # Examples
98///
99/// ```rust
100/// let (headers, content) = mailsis_utils::parse_raw_headers(
101///     "From: alice@example.com\r\nTo: bob@example.com\r\n\r\nHello!"
102/// );
103/// assert_eq!(headers.len(), 2);
104/// assert_eq!(headers[0], ("From".to_string(), "alice@example.com".to_string()));
105/// assert_eq!(content, "Hello!");
106/// ```
107pub fn parse_raw_headers(raw: &str) -> (Vec<(String, String)>, &str) {
108    let mut headers = Vec::new();
109    let mut pos = 0;
110
111    for line in raw.lines() {
112        let line_len = line.len();
113        let end = pos + line_len;
114        let consumed = if raw[end..].starts_with("\r\n") {
115            end + 2
116        } else if raw[end..].starts_with('\n') {
117            end + 1
118        } else {
119            end
120        };
121
122        if line.trim().is_empty() {
123            pos = consumed;
124            break;
125        }
126
127        if let Some((key, value)) = line.split_once(':') {
128            headers.push((key.trim().to_string(), value.trim().to_string()));
129        } else {
130            // Line is not a header (no colon) and not blank, treat as start of content
131            break;
132        }
133
134        pos = consumed;
135    }
136
137    (headers, &raw[pos..])
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn test_is_mime_valid_with_mime_version() {
146        assert!(is_mime_valid(
147            "MIME-Version: 1.0\r\nContent-Type: text/plain\r\n\r\nBody"
148        ));
149    }
150
151    #[test]
152    fn test_is_mime_valid_without_mime_version() {
153        assert!(!is_mime_valid("Subject: Hello\r\n\r\nBody"));
154    }
155
156    #[test]
157    fn test_is_mime_valid_mime_in_body_only() {
158        assert!(!is_mime_valid("Subject: Hello\r\n\r\nMIME-Version: 1.0"));
159    }
160
161    #[test]
162    fn test_is_mime_valid_empty_input() {
163        assert!(!is_mime_valid(""));
164    }
165
166    #[test]
167    fn test_is_mime_valid_no_headers() {
168        assert!(!is_mime_valid("Plain text without headers"));
169    }
170
171    #[test]
172    fn test_is_mime_valid_mime_version_among_headers() {
173        assert!(is_mime_valid(
174            "From: a@b.com\r\nMIME-Version: 1.0\r\nTo: c@d.com\r\n\r\nBody"
175        ));
176    }
177
178    #[test]
179    fn test_parse_mime_headers_basic() {
180        let headers =
181            parse_mime_headers("From: test@example.com\r\nTo: test@example.com\r\n").unwrap();
182        assert_eq!(headers.len(), 2);
183        assert_eq!(headers["From"], "test@example.com");
184        assert_eq!(headers["To"], "test@example.com");
185    }
186
187    #[test]
188    fn test_parse_mime_headers_with_body() {
189        let headers =
190            parse_mime_headers("From: a@b.com\r\nSubject: Test\r\n\r\nBody content here").unwrap();
191        assert_eq!(headers.len(), 2);
192        assert_eq!(headers["Subject"], "Test");
193        assert!(!headers.contains_key("Body content here"));
194    }
195
196    #[test]
197    fn test_parse_mime_headers_empty_input() {
198        let headers = parse_mime_headers("").unwrap();
199        assert!(headers.is_empty());
200    }
201
202    #[test]
203    fn test_parse_mime_headers_no_headers() {
204        let headers = parse_mime_headers("\r\nBody only").unwrap();
205        assert!(headers.is_empty());
206    }
207
208    #[test]
209    fn test_parse_mime_headers_duplicate_keys() {
210        let headers = parse_mime_headers("X-Custom: first\r\nX-Custom: second\r\n").unwrap();
211        assert_eq!(headers.len(), 1);
212        assert_eq!(headers["X-Custom"], "second");
213    }
214
215    #[test]
216    fn test_parse_raw_headers_basic() {
217        let (headers, content) =
218            parse_raw_headers("From: alice@example.com\r\nTo: bob@example.com\r\n\r\nHello!");
219        assert_eq!(headers.len(), 2);
220        assert_eq!(
221            headers[0],
222            ("From".to_string(), "alice@example.com".to_string())
223        );
224        assert_eq!(
225            headers[1],
226            ("To".to_string(), "bob@example.com".to_string())
227        );
228        assert_eq!(content, "Hello!");
229    }
230
231    #[test]
232    fn test_parse_raw_headers_preserves_order() {
233        let (headers, _) = parse_raw_headers("Z-Last: z\r\nA-First: a\r\nM-Middle: m\r\n\r\nBody");
234        assert_eq!(headers[0].0, "Z-Last");
235        assert_eq!(headers[1].0, "A-First");
236        assert_eq!(headers[2].0, "M-Middle");
237    }
238
239    #[test]
240    fn test_parse_raw_headers_duplicate_headers() {
241        let (headers, _) = parse_raw_headers("Received: first\r\nReceived: second\r\n\r\nBody");
242        assert_eq!(headers.len(), 2);
243        assert_eq!(headers[0].1, "first");
244        assert_eq!(headers[1].1, "second");
245    }
246
247    #[test]
248    fn test_parse_raw_headers_empty_input() {
249        let (headers, content) = parse_raw_headers("");
250        assert!(headers.is_empty());
251        assert_eq!(content, "");
252    }
253
254    #[test]
255    fn test_parse_raw_headers_no_headers() {
256        let (headers, content) = parse_raw_headers("Plain text body");
257        assert!(headers.is_empty());
258        assert_eq!(content, "Plain text body");
259    }
260
261    #[test]
262    fn test_parse_raw_headers_empty_body() {
263        let (headers, content) = parse_raw_headers("Subject: Test\r\n\r\n");
264        assert_eq!(headers.len(), 1);
265        assert_eq!(content, "");
266    }
267
268    #[test]
269    fn test_parse_raw_headers_lf_line_endings() {
270        let (headers, content) = parse_raw_headers("From: a@b.com\nTo: c@d.com\n\nBody");
271        assert_eq!(headers.len(), 2);
272        assert_eq!(content, "Body");
273    }
274
275    #[test]
276    fn test_parse_raw_headers_trims_values() {
277        let (headers, _) = parse_raw_headers("Subject:  spaced value  \r\n\r\nBody");
278        assert_eq!(headers[0].1, "spaced value");
279    }
280}