1use std::fs::{self, File};
8use std::io::{Cursor, Read};
9use std::path::Path;
10
11use cfb::CompoundFile;
12use encoding_rs::{Encoding, WINDOWS_1252};
13
14use crate::convert::{ConvertOptions, PageConsumer};
15use crate::document::html::{HtmlBlock, render_blocks_to_pages};
16use crate::error::{Error, Result};
17use crate::ir::Page;
18
19const MAX_MSG_BYTES: u64 = 64 * 1024 * 1024;
20const MAX_MSG_ENTRIES: usize = 50_000;
21const MAX_MSG_PATH_BYTES: usize = 1024;
22const MAX_MSG_PATHS_TOTAL_BYTES: usize = 8 * 1024 * 1024;
23const MAX_MSG_TEXT_BYTES: usize = 16 * 1024 * 1024;
24
25pub(crate) fn looks_like_msg_file(path: &Path) -> bool {
26 let Ok(metadata) = fs::metadata(path) else {
27 return false;
28 };
29 if metadata.len() < 512 || metadata.len() > MAX_MSG_BYTES {
30 return false;
31 }
32 let Ok(file) = File::open(path) else {
33 return false;
34 };
35 let mut bytes = Vec::new();
36 if Read::take(file, MAX_MSG_BYTES + 1)
37 .read_to_end(&mut bytes)
38 .is_err()
39 || bytes.len() as u64 > MAX_MSG_BYTES
40 {
41 return false;
42 }
43 if bytes.len() < 8 || bytes[..8] != [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1] {
44 return false;
45 }
46 let Ok(compound) = CompoundFile::open(Cursor::new(bytes.as_slice())) else {
47 return false;
48 };
49 compound.exists("/__properties_version1.0")
50}
51
52struct MsgPageSink<'a> {
53 inner: &'a mut dyn PageConsumer,
54 title: String,
55 warnings: &'a [String],
56}
57
58impl PageConsumer for MsgPageSink<'_> {
59 fn consume(&mut self, mut page: Page) -> Result<()> {
60 page.source_format = "msg".into();
61 if page.title.is_empty() {
62 page.title = self.title.clone();
63 }
64 for warning in self.warnings {
65 page.warn(warning.clone());
66 }
67 self.inner.consume(page)
68 }
69}
70
71pub(crate) fn convert(
72 path: &Path,
73 options: &ConvertOptions,
74 sink: &mut dyn PageConsumer,
75) -> Result<Vec<String>> {
76 let max_bytes = options.max_input_bytes.min(MAX_MSG_BYTES);
77 let mut bytes = Vec::new();
78 Read::take(File::open(path)?, max_bytes.saturating_add(1)).read_to_end(&mut bytes)?;
79 if bytes.len() as u64 > max_bytes {
80 return Err(Error::LimitExceeded(format!(
81 "Outlook MSG input exceeds maximum limit of {max_bytes} bytes"
82 )));
83 }
84 if bytes.len() < 8 || bytes[..8] != [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1] {
85 return Err(Error::InvalidInput(
86 "Outlook MSG input is not a Compound File Binary document".into(),
87 ));
88 }
89
90 let mut compound = CompoundFile::open(Cursor::new(bytes.as_slice())).map_err(|error| {
91 Error::InvalidInput(format!("Outlook MSG compound file is invalid: {error}"))
92 })?;
93 validate_entries(&compound, bytes.len())?;
94 if !compound.exists("/__properties_version1.0") {
95 return Err(Error::InvalidInput(
96 "Compound document does not contain the Outlook message property stream".into(),
97 ));
98 }
99
100 let mut text_bytes = 0usize;
101 let codepages = read_codepages(&mut compound, &mut text_bytes)?;
102 let mut warnings = Vec::new();
103 let metadata_codepage = codepages.message.or(codepages.internet);
104 let body_codepage = codepages.internet.or(codepages.message);
105 let subject = read_text_property(
106 &mut compound,
107 "0037",
108 metadata_codepage,
109 &mut text_bytes,
110 &mut warnings,
111 )?
112 .unwrap_or_default()
113 .trim()
114 .to_owned();
115 let title = if subject.is_empty() {
116 "Outlook message".to_owned()
117 } else {
118 subject.clone()
119 };
120 let sender_name = read_text_property(
121 &mut compound,
122 "0C1A",
123 metadata_codepage,
124 &mut text_bytes,
125 &mut warnings,
126 )?;
127 let sender_address = read_text_property(
128 &mut compound,
129 "0C1F",
130 metadata_codepage,
131 &mut text_bytes,
132 &mut warnings,
133 )?;
134 let to = read_text_property(
135 &mut compound,
136 "0E04",
137 metadata_codepage,
138 &mut text_bytes,
139 &mut warnings,
140 )?;
141 let cc = read_text_property(
142 &mut compound,
143 "0E03",
144 metadata_codepage,
145 &mut text_bytes,
146 &mut warnings,
147 )?;
148 let bcc = read_text_property(
149 &mut compound,
150 "0E02",
151 metadata_codepage,
152 &mut text_bytes,
153 &mut warnings,
154 )?;
155 let body = read_text_property(
156 &mut compound,
157 "1000",
158 body_codepage,
159 &mut text_bytes,
160 &mut warnings,
161 )?;
162 let html = read_binary_property(
163 &mut compound,
164 "1013",
165 codepages.internet,
166 &mut text_bytes,
167 &mut warnings,
168 )?;
169
170 let mut blocks = vec![HtmlBlock::Heading {
171 level: 1,
172 text: title.clone(),
173 }];
174 let from = format_sender(sender_name.as_deref(), sender_address.as_deref());
175 push_header(&mut blocks, "From", from.as_deref());
176 push_header(&mut blocks, "To", to.as_deref());
177 push_header(&mut blocks, "Cc", cc.as_deref());
178 push_header(&mut blocks, "Bcc", bcc.as_deref());
179 blocks.push(HtmlBlock::HorizontalRule);
180
181 if let Some(html) = html.filter(|html| !html.trim().is_empty()) {
182 if contains_ascii_case_insensitive(html.as_bytes(), b"<script")
183 || contains_ascii_case_insensitive(html.as_bytes(), b"<style")
184 {
185 warnings.push("active script and style content in Outlook HTML was omitted".into());
186 }
187 let (html_blocks, html_warnings, _) =
188 crate::document::html::parse_html_blocks_with_inline_images(
189 &html,
190 options.max_xml_events,
191 MAX_MSG_TEXT_BYTES,
192 &std::collections::HashMap::new(),
193 )?;
194 blocks.extend(html_blocks);
195 warnings.extend(html_warnings);
196 if body.as_ref().is_some_and(|body| !body.trim().is_empty()) {
197 warnings.push(
198 "Outlook plain-text alternative was omitted because an HTML body was present"
199 .into(),
200 );
201 }
202 } else if let Some(body) = body.filter(|body| !body.trim().is_empty()) {
203 append_plain_text(&mut blocks, &body);
204 } else {
205 warnings.push("Outlook message has no supported text body".into());
206 }
207
208 let attachment_count = count_attachments(&compound);
209 if attachment_count > 0 {
210 warnings.push(format!(
211 "{attachment_count} Outlook attachment(s), including inline images, were omitted"
212 ));
213 blocks.push(HtmlBlock::Paragraph {
214 text: format!("Attachments: {attachment_count} omitted"),
215 });
216 }
217 if compound.exists("/__substg1.0_007D001F")
218 || compound.exists("/__substg1.0_007D001E")
219 || compound.exists("/__substg1.0_007D0102")
220 {
221 warnings.push(
222 "Outlook transport headers beyond sender and recipient fields were omitted".into(),
223 );
224 }
225
226 let mut page_sink = MsgPageSink {
227 inner: sink,
228 title,
229 warnings: &warnings,
230 };
231 render_blocks_to_pages(&blocks, &mut page_sink, options)?;
232 Ok(warnings)
233}
234
235fn validate_entries(compound: &CompoundFile<Cursor<&[u8]>>, file_bytes: usize) -> Result<()> {
236 let mut entries = 0usize;
237 let mut total_path_bytes = 0usize;
238 for entry in compound.walk() {
239 entries = entries.saturating_add(1);
240 if entries > MAX_MSG_ENTRIES {
241 return Err(Error::LimitExceeded(format!(
242 "Outlook MSG contains more than {MAX_MSG_ENTRIES} compound entries"
243 )));
244 }
245 let path_bytes = entry.path().to_string_lossy().len();
246 if path_bytes > MAX_MSG_PATH_BYTES {
247 return Err(Error::LimitExceeded(format!(
248 "Outlook MSG compound path exceeds {MAX_MSG_PATH_BYTES} bytes"
249 )));
250 }
251 total_path_bytes = total_path_bytes.saturating_add(path_bytes);
252 if total_path_bytes > MAX_MSG_PATHS_TOTAL_BYTES {
253 return Err(Error::LimitExceeded(format!(
254 "Outlook MSG paths exceed {MAX_MSG_PATHS_TOTAL_BYTES} bytes"
255 )));
256 }
257 if entry.is_stream() && entry.len() > file_bytes as u64 {
258 return Err(Error::InvalidInput(
259 "Outlook MSG stream declares more bytes than its container".into(),
260 ));
261 }
262 }
263 Ok(())
264}
265
266fn count_attachments(compound: &CompoundFile<Cursor<&[u8]>>) -> usize {
267 compound
268 .walk()
269 .filter(|entry| {
270 entry.is_storage()
271 && entry
272 .name()
273 .to_ascii_lowercase()
274 .starts_with("__attach_version1.0_#")
275 })
276 .count()
277}
278
279fn read_text_property(
280 compound: &mut CompoundFile<Cursor<&[u8]>>,
281 property_id: &str,
282 codepage: Option<u32>,
283 total_bytes: &mut usize,
284 warnings: &mut Vec<String>,
285) -> Result<Option<String>> {
286 for suffix in ["001F", "001E"] {
287 let path = format!("/__substg1.0_{property_id}{suffix}");
288 if let Some(bytes) = read_named_stream(compound, &path, MAX_MSG_TEXT_BYTES, total_bytes)? {
289 let value = if suffix == "001F" {
290 if bytes.len() % 2 != 0 {
291 return Err(Error::InvalidInput(format!(
292 "Outlook Unicode property {property_id} has an odd byte length"
293 )));
294 }
295 let units = bytes
296 .chunks_exact(2)
297 .map(|pair| u16::from_le_bytes([pair[0], pair[1]]))
298 .collect::<Vec<_>>();
299 String::from_utf16_lossy(&units)
300 } else {
301 let encoding = codepage.and_then(encoding_for_codepage).unwrap_or_else(|| {
302 push_warning_once(
303 warnings,
304 "Outlook ANSI text used a missing or unsupported code page; Windows-1252 fallback was used",
305 );
306 WINDOWS_1252
307 });
308 let (decoded, had_errors) = encoding.decode_without_bom_handling(&bytes);
309 if had_errors {
310 push_warning_once(
311 warnings,
312 "Outlook ANSI text contained invalid byte sequences for its declared code page",
313 );
314 }
315 decoded.into_owned()
316 };
317 return Ok(Some(clean_text(&value)));
318 }
319 }
320 Ok(None)
321}
322
323fn read_binary_property(
324 compound: &mut CompoundFile<Cursor<&[u8]>>,
325 property_id: &str,
326 codepage: Option<u32>,
327 total_bytes: &mut usize,
328 warnings: &mut Vec<String>,
329) -> Result<Option<String>> {
330 let path = format!("/__substg1.0_{property_id}0102");
331 let Some(bytes) = read_named_stream(compound, &path, MAX_MSG_TEXT_BYTES, total_bytes)? else {
332 return Ok(None);
333 };
334 let bytes = bytes.strip_prefix(&[0xef, 0xbb, 0xbf]).unwrap_or(&bytes);
335 let encoding = codepage.and_then(encoding_for_codepage);
336 let (html, had_errors) = if let Some(encoding) = encoding {
337 encoding.decode_without_bom_handling(bytes)
338 } else if let Ok(html) = std::str::from_utf8(bytes) {
339 (html.into(), false)
340 } else {
341 push_warning_once(
342 warnings,
343 "Outlook HTML body had no supported code page; UTF-8/Windows-1252 fallback was used",
344 );
345 WINDOWS_1252.decode_without_bom_handling(bytes)
346 };
347 if had_errors {
348 push_warning_once(
349 warnings,
350 "Outlook HTML body contained invalid byte sequences for its declared code page",
351 );
352 }
353 Ok(Some(html.into_owned()))
354}
355
356#[derive(Default)]
357struct Codepages {
358 internet: Option<u32>,
359 message: Option<u32>,
360}
361
362fn read_codepages(
363 compound: &mut CompoundFile<Cursor<&[u8]>>,
364 total_bytes: &mut usize,
365) -> Result<Codepages> {
366 let Some(bytes) = read_named_stream(
367 compound,
368 "/__properties_version1.0",
369 1024 * 1024,
370 total_bytes,
371 )?
372 else {
373 return Ok(Codepages::default());
374 };
375 if bytes.len() < 32 || (bytes.len() - 32) % 16 != 0 {
376 return Err(Error::InvalidInput(
377 "Outlook MSG property stream has an invalid header or entry length".into(),
378 ));
379 }
380 let mut codepages = Codepages::default();
381 for entry in bytes[32..].chunks_exact(16) {
382 let property_type = u16::from_le_bytes([entry[0], entry[1]]);
383 if property_type != 0x0003 {
384 continue;
385 }
386 let property_id = u16::from_le_bytes([entry[2], entry[3]]);
387 let value = u32::from_le_bytes([entry[8], entry[9], entry[10], entry[11]]);
388 match property_id {
389 0x3FDE => codepages.internet = Some(value),
390 0x3FFD => codepages.message = Some(value),
391 _ => {}
392 }
393 }
394 Ok(codepages)
395}
396
397fn encoding_for_codepage(codepage: u32) -> Option<&'static Encoding> {
398 let label = match codepage {
399 65001 => "utf-8".to_owned(),
400 932 => "shift_jis".to_owned(),
401 936 => "gbk".to_owned(),
402 949 => "euc-kr".to_owned(),
403 950 => "big5".to_owned(),
404 874 | 1250..=1258 => format!("windows-{codepage}"),
405 _ => return None,
406 };
407 Encoding::for_label(label.as_bytes())
408}
409
410fn push_warning_once(warnings: &mut Vec<String>, warning: &str) {
411 if !warnings.iter().any(|existing| existing == warning) {
412 warnings.push(warning.to_owned());
413 }
414}
415
416fn read_named_stream(
417 compound: &mut CompoundFile<Cursor<&[u8]>>,
418 path: &str,
419 max_bytes: usize,
420 total_bytes: &mut usize,
421) -> Result<Option<Vec<u8>>> {
422 if !compound.exists(path) {
423 return Ok(None);
424 }
425 let mut stream = compound
426 .open_stream(path)
427 .map_err(|error| Error::InvalidInput(format!("cannot read Outlook MSG stream: {error}")))?;
428 let mut bytes = Vec::new();
429 Read::take(&mut stream, max_bytes.saturating_add(1) as u64).read_to_end(&mut bytes)?;
430 if bytes.len() > max_bytes {
431 return Err(Error::LimitExceeded(format!(
432 "Outlook MSG property stream exceeds {max_bytes} bytes"
433 )));
434 }
435 *total_bytes = total_bytes
436 .checked_add(bytes.len())
437 .ok_or_else(|| Error::LimitExceeded("Outlook MSG text size overflowed".into()))?;
438 if *total_bytes > MAX_MSG_TEXT_BYTES {
439 return Err(Error::LimitExceeded(format!(
440 "Outlook MSG text properties exceed {MAX_MSG_TEXT_BYTES} bytes"
441 )));
442 }
443 Ok(Some(bytes))
444}
445
446fn format_sender(name: Option<&str>, address: Option<&str>) -> Option<String> {
447 match (
448 name.map(str::trim).filter(|value| !value.is_empty()),
449 address.map(str::trim).filter(|value| !value.is_empty()),
450 ) {
451 (Some(name), Some(address)) => Some(format!("{name} <{address}>")),
452 (Some(name), None) => Some(name.to_owned()),
453 (None, Some(address)) => Some(address.to_owned()),
454 (None, None) => None,
455 }
456}
457
458fn push_header(blocks: &mut Vec<HtmlBlock>, label: &str, value: Option<&str>) {
459 if let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) {
460 blocks.push(HtmlBlock::Paragraph {
461 text: format!("{label}: {value}"),
462 });
463 }
464}
465
466fn append_plain_text(blocks: &mut Vec<HtmlBlock>, body: &str) {
467 for paragraph in body.split("\n\n") {
468 let text = paragraph
469 .lines()
470 .map(str::trim_end)
471 .collect::<Vec<_>>()
472 .join(" ")
473 .trim()
474 .to_owned();
475 if !text.is_empty() {
476 blocks.push(HtmlBlock::Paragraph { text });
477 }
478 }
479}
480
481fn clean_text(text: &str) -> String {
482 text.chars()
483 .map(|character| if character == '\r' { '\n' } else { character })
484 .filter(|character| matches!(character, '\n' | '\t') || !character.is_control())
485 .collect()
486}
487
488fn contains_ascii_case_insensitive(haystack: &[u8], needle: &[u8]) -> bool {
489 !needle.is_empty()
490 && haystack
491 .windows(needle.len())
492 .any(|window| window.eq_ignore_ascii_case(needle))
493}