Skip to main content

mxr_export/
mbox.rs

1use crate::ExportThread;
2
3/// Export thread as RFC 4155 mbox format.
4/// Each message starts with "From " line followed by RFC 2822 headers + body.
5pub fn export_mbox(thread: &ExportThread) -> String {
6    let mut out = String::new();
7
8    for msg in &thread.messages {
9        // Mbox "From " line: From sender@email.com Tue Mar 17 09:45:00 2026
10        let mbox_date = msg.date.format("%a %b %e %H:%M:%S %Y");
11        out.push_str(&format!("From {} {}\r\n", msg.from_email, mbox_date));
12
13        // Headers
14        if let Some(raw) = &msg.headers_raw {
15            let raw = raw.replace("\r\n", "\n").replace('\n', "\r\n");
16            out.push_str(raw.trim_end_matches("\r\n"));
17            out.push_str("\r\n");
18        } else {
19            // Reconstruct minimal headers
20            if let Some(name) = &msg.from_name {
21                out.push_str(&format!("From: {} <{}>\r\n", name, msg.from_email));
22            } else {
23                out.push_str(&format!("From: {}\r\n", msg.from_email));
24            }
25            out.push_str(&format!("Subject: {}\r\n", msg.subject));
26            out.push_str(&format!("Date: {}\r\n", msg.date.to_rfc2822()));
27            if !msg.to.is_empty() {
28                out.push_str(&format!("To: {}\r\n", msg.to.join(", ")));
29            }
30        }
31        out.push_str("\r\n");
32
33        // Body (escape lines starting with "From " per mbox convention)
34        if let Some(text) = &msg.body_text {
35            for line in text.lines() {
36                if line.starts_with("From ") {
37                    out.push('>');
38                }
39                out.push_str(line);
40                out.push_str("\r\n");
41            }
42        }
43        out.push_str("\r\n");
44    }
45
46    out
47}
48
49#[cfg(test)]
50mod tests {
51    use super::*;
52    use crate::tests::{empty_body_thread, sample_thread};
53    use crate::{ExportMessage, ExportThread};
54    use chrono::TimeZone;
55    use mxr_test_support::standards_fixture_string;
56
57    #[test]
58    fn mbox_starts_with_from_line() {
59        let result = export_mbox(&sample_thread());
60        assert!(result.starts_with("From alice@example.com"));
61    }
62
63    #[test]
64    fn mbox_from_line_has_asctime_date() {
65        let result = export_mbox(&sample_thread());
66        let first_line = result.lines().next().unwrap();
67        // asctime format: "Tue Mar 17 09:30:00 2026"
68        assert!(first_line.contains("Tue Mar 17"));
69        assert!(first_line.contains("09:30:00 2026"));
70    }
71
72    #[test]
73    fn mbox_reconstructs_minimal_headers_when_no_raw() {
74        let result = export_mbox(&sample_thread());
75        assert!(result.contains("From: Alice <alice@example.com>"));
76        assert!(result.contains("Subject: Deployment rollback plan"));
77        assert!(result.contains("Date: "));
78        assert!(result.contains("To: team@example.com"));
79    }
80
81    #[test]
82    fn mbox_uses_raw_headers_when_available() {
83        let mut thread = sample_thread();
84        thread.messages[0].headers_raw =
85            Some("From: custom@header.com\r\nX-Custom: yes\r\n".into());
86        let result = export_mbox(&thread);
87        assert!(result.contains("X-Custom: yes"));
88        // Should NOT contain reconstructed headers
89        assert!(!result.contains("From: Alice <alice@example.com>"));
90    }
91
92    #[test]
93    fn mbox_escapes_from_in_body() {
94        let mut thread = sample_thread();
95        thread.messages[0].body_text =
96            Some("From the beginning of time\nNormal line\nFrom space comes light".into());
97        let result = export_mbox(&thread);
98        assert!(result.contains(">From the beginning of time"));
99        assert!(result.contains("Normal line"));
100        assert!(result.contains(">From space comes light"));
101    }
102
103    #[test]
104    fn mbox_does_not_escape_from_mid_line() {
105        let mut thread = sample_thread();
106        thread.messages[0].body_text = Some("This is From the meeting".into());
107        let result = export_mbox(&thread);
108        // "From" not at start of line — no escaping needed
109        assert!(result.contains("This is From the meeting"));
110    }
111
112    #[test]
113    fn mbox_multiple_messages_separated_by_from_lines() {
114        let result = export_mbox(&sample_thread());
115        let from_lines: Vec<&str> = result
116            .lines()
117            .filter(|l| l.starts_with("From ") && l.contains('@'))
118            .collect();
119        assert_eq!(from_lines.len(), 2);
120    }
121
122    #[test]
123    fn mbox_handles_empty_body() {
124        let result = export_mbox(&empty_body_thread());
125        // Should still produce valid mbox with From line and headers
126        assert!(result.starts_with("From ghost@void.com"));
127        assert!(result.contains("Subject: No body"));
128    }
129
130    #[test]
131    fn mbox_omits_to_header_when_empty() {
132        let result = export_mbox(&empty_body_thread());
133        assert!(!result.contains("To: "));
134    }
135
136    #[test]
137    fn mbox_from_header_omits_name_when_missing() {
138        let thread = ExportThread {
139            thread_id: "t".into(),
140            subject: "test".into(),
141            messages: vec![ExportMessage {
142                id: "m".into(),
143                from_name: None,
144                from_email: "plain@example.com".into(),
145                to: vec![],
146                date: chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
147                subject: "test".into(),
148                body_text: None,
149                body_html: None,
150                headers_raw: None,
151                attachments: vec![],
152            }],
153        };
154        let result = export_mbox(&thread);
155        // Should be "From: plain@example.com" not "From:  <plain@example.com>"
156        assert!(result.contains("From: plain@example.com\r\n"));
157        assert!(!result.contains("From:  <"));
158    }
159
160    #[test]
161    fn mbox_uses_crlf_line_endings() {
162        let result = export_mbox(&sample_thread());
163        assert!(result.contains("\r\nSubject: Deployment rollback plan\r\n"));
164        assert!(result.contains("\r\n\r\nWhat's the rollback strategy"));
165        assert!(!result.contains("Subject: Deployment rollback plan\nDate:"));
166    }
167
168    #[test]
169    fn standards_fixture_exports_as_mbox_snapshot() {
170        let raw = standards_fixture_string("folded-flowed.eml");
171        let (headers, body) = raw.split_once("\n\n").unwrap();
172        let thread = ExportThread {
173            thread_id: "fixture-thread".into(),
174            subject: "Fixture Subject".into(),
175            messages: vec![ExportMessage {
176                id: "fixture-1".into(),
177                from_name: Some("José Example".into()),
178                from_email: "jose@example.com".into(),
179                to: vec!["team@example.com".into()],
180                date: chrono::DateTime::parse_from_rfc2822("Fri, 15 Mar 2024 09:30:00 +0000")
181                    .unwrap()
182                    .with_timezone(&chrono::Utc),
183                subject: "Quarterly update".into(),
184                body_text: Some(body.to_string()),
185                body_html: None,
186                headers_raw: Some(headers.replace('\n', "\r\n")),
187                attachments: vec![],
188            }],
189        };
190
191        insta::assert_snapshot!("fixture_mbox_export", export_mbox(&thread));
192    }
193}