1use crate::ExportThread;
2
3pub fn export_mbox(thread: &ExportThread) -> String {
6 let mut out = String::new();
7
8 for msg in &thread.messages {
9 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 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 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 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 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 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 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 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 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}