Skip to main content

document_svg/document/
msg.rs

1//! Bounded Microsoft Outlook `.msg` message preview.
2//!
3//! The CFB container is opened without extracting attachment payloads. Common
4//! message properties and a safe HTML/plain-text body are rendered; embedded
5//! content is never executed or fetched.
6
7use 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}