Skip to main content

hypermail/
mbox.rs

1use crate::error::{HypermailError, Result};
2use std::io::{BufRead, BufReader, Read};
3
4/// Maximum size for a single email message (100 MB).
5///
6/// This prevents denial-of-service attacks via extremely large messages
7/// that could exhaust memory. Legitimate emails are typically < 50 MB
8/// even with large attachments.
9const MAX_MESSAGE_SIZE: usize = 100 * 1024 * 1024;
10
11/// Maximum line length for email headers or body lines (10 MB).
12///
13/// RFC 2822 recommends lines < 998 characters, but we allow much larger
14/// for compatibility with malformed messages and large MIME parts.
15/// This prevents memory exhaustion from pathological single-line inputs.
16const MAX_LINE_SIZE: usize = 10 * 1024 * 1024;
17
18/// Supported mbox format variants for "From " line escaping strategies.
19#[derive(Debug, Clone, PartialEq)]
20pub enum MboxFormat {
21    MboxO,
22    MboxRd,
23    MboxCl,
24    MboxCl2,
25}
26
27/// A single raw email message split into envelope line, headers, and body bytes.
28#[derive(Debug, Clone)]
29pub struct RawMessage {
30    pub from_line: String,
31    pub headers: Vec<u8>,
32    pub body: Vec<u8>,
33}
34
35/// Streaming iterator that splits an mbox file into individual messages.
36///
37/// # Security
38///
39/// Enforces per-message and per-line size limits to prevent memory exhaustion
40/// from malicious or malformed input.
41pub struct MboxReader<R: Read> {
42    reader: BufReader<R>,
43    format: MboxFormat,
44    line_num: usize,
45    buffer: Vec<u8>,
46    eof: bool,
47    max_message_size: usize,
48}
49
50impl<R: Read> MboxReader<R> {
51    /// Creates a new mbox reader with the given format variant.
52    pub fn new(reader: R, format: MboxFormat) -> Self {
53        MboxReader {
54            reader: BufReader::new(reader),
55            format,
56            line_num: 0,
57            buffer: Vec::new(),
58            eof: false,
59            max_message_size: MAX_MESSAGE_SIZE,
60        }
61    }
62
63    /// Overrides the default maximum message size limit.
64    pub fn with_max_message_size(mut self, size: usize) -> Self {
65        self.max_message_size = size;
66        self
67    }
68
69    fn is_from_line(line: &[u8]) -> bool {
70        line.starts_with(b"From ") && line.len() > 5
71    }
72
73    fn unescape_mboxrd(line: &[u8]) -> Vec<u8> {
74        // MboxRd: strip exactly one leading '>' from lines that start with
75        // one or more '>' followed by "From ". This correctly handles
76        // multi-level escaping: ">>From " → ">From ", ">>>From " → ">>From ".
77        if line.len() > 1 && line[0] == b'>' {
78            // Count leading '>' characters
79            let gt_count = line.iter().take_while(|&&b| b == b'>').count();
80            // Check if after the '>' sequence we have "From "
81            if line[gt_count..].starts_with(b"From ") {
82                return line[1..].to_vec();
83            }
84        }
85        line.to_vec()
86    }
87}
88
89impl<R: Read> Iterator for MboxReader<R> {
90    type Item = Result<RawMessage>;
91
92    fn next(&mut self) -> Option<Self::Item> {
93        if self.eof {
94            return None;
95        }
96
97        loop {
98            let mut line = Vec::new();
99            self.line_num += 1;
100
101            match self.reader.read_until(b'\n', &mut line) {
102                Ok(0) => {
103                    self.eof = true;
104                    if self.buffer.is_empty() {
105                        return None;
106                    }
107                    break;
108                },
109                Ok(_) => {},
110                Err(e) => {
111                    return Some(Err(HypermailError::MboxParse {
112                        line: self.line_num,
113                        message: format!("read error: {e}"),
114                    }))
115                },
116            }
117
118            // Security: Check line size to prevent memory exhaustion
119            // from pathological inputs with extremely long lines
120            if line.len() > MAX_LINE_SIZE {
121                return Some(Err(HypermailError::MboxParse {
122                    line: self.line_num,
123                    message: format!(
124                        "line exceeds maximum size ({} bytes > {} bytes)",
125                        line.len(),
126                        MAX_LINE_SIZE
127                    ),
128                }));
129            }
130
131            if line.last() == Some(&b'\n') {
132                line.pop();
133                if line.last() == Some(&b'\r') {
134                    line.pop();
135                }
136            }
137
138            if self.buffer.is_empty() {
139                if Self::is_from_line(&line) {
140                    self.buffer = line;
141                    self.buffer.push(b'\n');
142                    continue;
143                }
144                self.buffer = line;
145                self.buffer.push(b'\n');
146                continue;
147            }
148
149            if Self::is_from_line(&line) {
150                let raw = self.buffer.split_off(0);
151                self.buffer = line;
152                self.buffer.push(b'\n');
153                return Some(Ok(parse_raw_message(&raw)));
154            }
155
156            // Security: Check total message size before appending
157            // Prevents denial-of-service via extremely large messages
158            let new_size = self.buffer.len() + line.len() + 1;
159            if new_size > self.max_message_size {
160                self.buffer.clear();
161                return Some(Err(HypermailError::MboxParse {
162                    line: self.line_num,
163                    message: format!(
164                        "message exceeds maximum size ({} bytes > {} bytes)",
165                        new_size, self.max_message_size
166                    ),
167                }));
168            }
169
170            if self.format == MboxFormat::MboxRd {
171                let unescaped = Self::unescape_mboxrd(&line);
172                self.buffer.extend_from_slice(&unescaped);
173            } else {
174                self.buffer.extend_from_slice(&line);
175            }
176            self.buffer.push(b'\n');
177        }
178
179        if self.buffer.is_empty() {
180            return None;
181        }
182        let raw = std::mem::take(&mut self.buffer);
183        Some(Ok(parse_raw_message(&raw)))
184    }
185}
186
187fn parse_raw_message(data: &[u8]) -> RawMessage {
188    let from_end = data.iter().position(|&b| b == b'\n').unwrap_or(data.len());
189    let from_line = String::from_utf8_lossy(&data[..from_end]).trim_end().to_string();
190
191    let rest = if from_end + 1 < data.len() {
192        &data[from_end + 1..]
193    } else {
194        &[]
195    };
196
197    let sep = rest
198        .windows(2)
199        .position(|w| w == b"\n\n")
200        .or_else(|| rest.windows(4).position(|w| w == b"\r\n\r\n").map(|p| p + 2));
201
202    if let Some(headers_end) = sep {
203        let header_bytes = rest[..headers_end].to_vec();
204        let body_bytes = if headers_end + 2 < rest.len() {
205            let skip = if rest[headers_end..].starts_with(b"\r\n") {
206                4
207            } else {
208                2
209            };
210            if headers_end + skip < rest.len() {
211                rest[headers_end + skip..].to_vec()
212            } else {
213                Vec::new()
214            }
215        } else {
216            Vec::new()
217        };
218        RawMessage { from_line, headers: header_bytes, body: body_bytes }
219    } else {
220        RawMessage { from_line, headers: rest.to_vec(), body: Vec::new() }
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227    use std::io::Cursor;
228
229    fn create_mbox_data() -> Vec<u8> {
230        b"From alice@example.com Mon Jan 01 12:00:00 2024\n\
231          From: Alice <alice@example.com>\n\
232          Subject: First message\n\
233          Message-ID: <001@example.com>\n\
234          Date: Mon, 01 Jan 2024 12:00:00 +0000\n\
235          \n\
236          This is the first message body.\n\
237          \n\
238          From bob@example.com Mon Jan 01 13:00:00 2024\n\
239          From: Bob <bob@example.com>\n\
240          Subject: Re: First message\n\
241          Message-ID: <002@example.com>\n\
242          In-Reply-To: <001@example.com>\n\
243          Date: Mon, 01 Jan 2024 13:00:00 +0000\n\
244          \n\
245          This is a reply.\n\
246          \n\
247          From carol@example.com Mon Jan 01 14:00:00 2024\n\
248          From: Carol <carol@example.com>\n\
249          Subject: Another thread\n\
250          Message-ID: <003@example.com>\n\
251          Date: Mon, 01 Jan 2024 14:00:00 +0000\n\
252          \n\
253          A different conversation.\n"
254            .to_vec()
255    }
256
257    #[test]
258    fn test_parse_mbox_basic() {
259        let data = create_mbox_data();
260        let cursor = Cursor::new(data);
261        let reader = MboxReader::new(cursor, MboxFormat::MboxO);
262        let messages: Vec<Result<RawMessage>> = reader.collect();
263        assert_eq!(messages.len(), 3);
264
265        let msg0 = messages[0].as_ref().unwrap();
266        assert!(msg0.from_line.contains("alice@example.com"));
267
268        let headers = crate::headers::parse_headers(&msg0.headers);
269        assert_eq!(crate::headers::find_header(&headers, "Subject"), Some("First message"));
270    }
271
272    #[test]
273    fn test_from_line_parsing() {
274        assert!(MboxReader::<std::io::Empty>::is_from_line(b"From alice@example.com Mon Jan 01"));
275        assert!(!MboxReader::<std::io::Empty>::is_from_line(b"From: alice@example.com"));
276        assert!(!MboxReader::<std::io::Empty>::is_from_line(b""));
277    }
278
279    #[test]
280    fn test_empty_mbox() {
281        let cursor = Cursor::new(b"");
282        let reader = MboxReader::new(cursor, MboxFormat::MboxO);
283        let count = reader.count();
284        assert_eq!(count, 0);
285    }
286
287    #[test]
288    fn test_mbox_no_headers() {
289        let data = b"From alice@example.com\n\nJust a body\n".to_vec();
290        let cursor = Cursor::new(data);
291        let reader = MboxReader::new(cursor, MboxFormat::MboxO);
292        let messages: Vec<Result<RawMessage>> = reader.collect();
293        assert_eq!(messages.len(), 1);
294        assert!(messages[0].is_ok());
295    }
296
297    #[test]
298    fn test_multipart_message_with_from_in_body() {
299        let data = b"From alice@example.com\n\
300                     From: Alice <alice@example.com>\n\
301                     Subject: Test\n\
302                     \n\
303                     This line looks like\n\
304                     >From someone else\n\
305                     but shouldn't be split.\n"
306            .to_vec();
307        let cursor = Cursor::new(data);
308        let reader = MboxReader::new(cursor, MboxFormat::MboxRd);
309        let messages: Vec<Result<RawMessage>> = reader.collect();
310        assert_eq!(messages.len(), 1);
311        let msg = messages[0].as_ref().unwrap();
312        assert_eq!(msg.from_line, "From alice@example.com");
313        let body_str = std::str::from_utf8(&msg.body).unwrap();
314        assert!(body_str.contains("From someone else"));
315    }
316
317    #[test]
318    fn test_max_message_size_exceeded() {
319        let data = b"From alice@example.com\n\
320                     From: Alice <alice@example.com>\n\
321                     \n\
322                     This is a very long body line that exceeds our tiny limit.\n"
323            .to_vec();
324        let cursor = Cursor::new(data);
325        let reader = MboxReader::new(cursor, MboxFormat::MboxO).with_max_message_size(10);
326        let results: Vec<Result<RawMessage>> = reader.collect();
327        assert!(
328            results.iter().any(|r| r.is_err()),
329            "Should fail when message exceeds size limit"
330        );
331    }
332
333    #[test]
334    fn test_mboxrd_unescape() {
335        // MboxRd: ">From " at start of body line is an escaped "From " and must be unescaped
336        let data = b"From alice@example.com\n\
337                     Subject: Test\n\
338                     \n\
339                     >From someone we know\n"
340            .to_vec();
341        let cursor = Cursor::new(data);
342        let reader = MboxReader::new(cursor, MboxFormat::MboxRd);
343        let messages: Vec<Result<RawMessage>> = reader.collect();
344        assert_eq!(messages.len(), 1);
345        let msg = messages[0].as_ref().unwrap();
346        let body = std::str::from_utf8(&msg.body).unwrap();
347        assert!(body.contains("From someone"), "'>From' should be unescaped to 'From'");
348        assert!(!body.contains(">From"), "unescaped body should not contain '>From'");
349    }
350
351    #[test]
352    fn test_parse_mbox_three_messages_bodies() {
353        let data = create_mbox_data();
354        let cursor = Cursor::new(data);
355        let reader = MboxReader::new(cursor, MboxFormat::MboxO);
356        let messages: Vec<Result<RawMessage>> = reader.collect();
357        assert_eq!(messages.len(), 3);
358        let body0 = std::str::from_utf8(&messages[0].as_ref().unwrap().body).unwrap();
359        assert!(body0.contains("first message body"));
360        let body1 = std::str::from_utf8(&messages[1].as_ref().unwrap().body).unwrap();
361        assert!(body1.contains("reply"));
362    }
363}