Skip to main content

document_svg/document/
mhtml.rs

1//! Bounded MHTML (`.mht`/`.mhtml`) web-archive preview.
2//!
3//! The MIME root HTML part is parsed by the existing safe HTML subset and
4//! typeset as SVG pages. Only validated, bounded PNG/JPEG resources referenced
5//! by Content-ID or resolved Content-Location matches in the same or enclosing
6//! multipart/related
7//! are embedded; remote resources, scripts, stylesheets, and other attachments are omitted.
8
9use std::fs::File;
10use std::io::Read;
11use std::path::Path;
12
13use mail_parser::{Message, MessageParser, MimeHeaders, PartType};
14
15use crate::convert::{ConvertOptions, PageConsumer};
16use crate::document::eml::{self, MAX_EML_BYTES};
17use crate::document::html::{
18    HtmlBlock, first_html_base_href, parse_html_blocks_with_inline_images_resolved_budgeted,
19    render_blocks_to_pages,
20};
21use crate::document::mime_images::{
22    MimeImageResources, collect_mime_images_with_uri_resolution, resolve_mime_uri,
23};
24use crate::error::{Error, Result};
25use crate::ir::Page;
26
27const MAX_MHTML_HTML_BYTES: usize = 32 * 1024 * 1024;
28const MAX_MHTML_NORMALIZED_HTML_BYTES: usize = 64 * 1024 * 1024;
29const MAX_MHTML_PARTS: usize = 20_000;
30
31struct MhtmlPageSink<'a> {
32    inner: &'a mut dyn PageConsumer,
33    title: String,
34    warnings: &'a [String],
35}
36
37#[derive(Clone, Copy)]
38enum MhtmlBodyPart {
39    Html { part_id: u32 },
40    Text { part_id: u32 },
41}
42
43impl PageConsumer for MhtmlPageSink<'_> {
44    fn consume(&mut self, mut page: Page) -> Result<()> {
45        page.source_format = "mhtml".into();
46        if page.title.is_empty() {
47            page.title = self.title.clone();
48        }
49        for warning in self.warnings {
50            page.warn(warning.clone());
51        }
52        self.inner.consume(page)
53    }
54}
55
56pub(crate) fn convert(
57    path: &Path,
58    options: &ConvertOptions,
59    sink: &mut dyn PageConsumer,
60) -> Result<Vec<String>> {
61    let max_bytes = options.max_input_bytes.min(MAX_EML_BYTES);
62    let mut bytes = Vec::new();
63    Read::take(File::open(path)?, max_bytes.saturating_add(1)).read_to_end(&mut bytes)?;
64    if bytes.len() as u64 > max_bytes {
65        return Err(Error::LimitExceeded(format!(
66            "MHTML input exceeds maximum limit of {max_bytes} bytes"
67        )));
68    }
69    eml::preflight_lines(&bytes)?;
70    let parser = MessageParser::default()
71        .with_minimal_headers()
72        .default_header_text();
73    let message = parser.parse(&bytes).ok_or_else(|| {
74        Error::InvalidInput("MHTML archive contains no parseable MIME message".into())
75    })?;
76    if message.parts.len() > MAX_MHTML_PARTS {
77        return Err(Error::LimitExceeded(format!(
78            "MHTML archive contains {} MIME parts; maximum is {MAX_MHTML_PARTS}",
79            message.parts.len()
80        )));
81    }
82
83    let title = message
84        .subject()
85        .map(clean_text)
86        .filter(|text| !text.trim().is_empty())
87        .unwrap_or_else(|| "MHTML web archive".into());
88    let (selected_body, related_found, mut warnings) = select_mhtml_body(&message)?;
89    let html_part_id = match selected_body {
90        Some(MhtmlBodyPart::Html { part_id, .. }) => Some(part_id),
91        _ => None,
92    };
93    let MimeImageResources {
94        images: cid_images,
95        part_ids: cid_part_ids,
96        part_count: cid_part_count,
97        warnings: image_warnings,
98        base_uri: mime_base_uri,
99    } = html_part_id
100        .map(|html_part_id| collect_mime_images_with_uri_resolution(&message, Some(html_part_id)))
101        .unwrap_or_default();
102    warnings.extend(image_warnings);
103    let mut used_cid_images = std::collections::HashSet::new();
104    let mut blocks = match selected_body {
105        Some(MhtmlBodyPart::Html { part_id }) => {
106            let html = message
107                .parts
108                .get(part_id as usize)
109                .and_then(|part| part.text_contents())
110                .unwrap_or_default();
111            let html = clean_text(html);
112            if html.len() > MAX_MHTML_HTML_BYTES {
113                return Err(Error::LimitExceeded(format!(
114                    "MHTML HTML part exceeds {MAX_MHTML_HTML_BYTES} bytes"
115                )));
116            }
117            let mime_base_uri = mime_base_uri.unwrap_or_else(|| "thismessage:/".to_owned());
118            let html_base_uri = if let Some(base_href) = first_html_base_href(
119                &html,
120                options.max_xml_events,
121                MAX_MHTML_NORMALIZED_HTML_BYTES,
122            )? {
123                match resolve_mime_uri(&mime_base_uri, &base_href) {
124                    Some(resolved) => resolved,
125                    None => {
126                        push_mhtml_warning_once(
127                            &mut warnings,
128                            "invalid or overlong HTML base URI was ignored",
129                        );
130                        mime_base_uri
131                    }
132                }
133            } else {
134                mime_base_uri
135            };
136            let (blocks, html_warnings, used_images) =
137                parse_html_blocks_with_inline_images_resolved_budgeted(
138                    &html,
139                    options.max_xml_events,
140                    MAX_MHTML_NORMALIZED_HTML_BYTES,
141                    &cid_images,
142                    &html_base_uri,
143                )?;
144            warnings.extend(html_warnings);
145            used_cid_images = used_images;
146            if html.to_ascii_lowercase().contains("<style") {
147                warnings.push("MHTML CSS styling is not applied".into());
148            }
149            blocks
150        }
151        Some(MhtmlBodyPart::Text { part_id }) => {
152            let text = clean_text(
153                message
154                    .parts
155                    .get(part_id as usize)
156                    .and_then(|part| part.text_contents())
157                    .unwrap_or_default(),
158            );
159            if text.len() > MAX_MHTML_HTML_BYTES {
160                return Err(Error::LimitExceeded(format!(
161                    "MHTML text part exceeds {MAX_MHTML_HTML_BYTES} bytes"
162                )));
163            }
164            text.split("\n\n")
165                .map(str::trim)
166                .filter(|paragraph| !paragraph.is_empty())
167                .map(|paragraph| HtmlBlock::Paragraph {
168                    text: paragraph.to_owned(),
169                })
170                .collect()
171        }
172        None => Vec::new(),
173    };
174    if blocks.is_empty() {
175        warnings.push(if related_found {
176            "MHTML related root contains no supported HTML/text body".into()
177        } else {
178            "MHTML archive contains no supported HTML/text body".into()
179        });
180    }
181    let used_cid_part_count = used_cid_images
182        .iter()
183        .filter_map(|key| cid_part_ids.get(key))
184        .copied()
185        .collect::<std::collections::HashSet<_>>()
186        .len();
187    let omitted_resource_parts = message
188        .attachment_count()
189        .saturating_sub(used_cid_part_count)
190        .max(cid_part_count.saturating_sub(used_cid_part_count));
191    if omitted_resource_parts > 0 {
192        warnings.push(format!(
193            "{omitted_resource_parts} MHTML resource/attachment part(s) were omitted"
194        ));
195    }
196    if message.parts.iter().any(|part| part.is_encoding_problem) {
197        warnings.push("one or more MHTML MIME parts contain transfer-encoding errors".into());
198    }
199    if !title.is_empty() {
200        blocks.insert(
201            0,
202            HtmlBlock::Heading {
203                level: 1,
204                text: title.clone(),
205            },
206        );
207    }
208    let mut page_sink = MhtmlPageSink {
209        inner: sink,
210        title,
211        warnings: &warnings,
212    };
213    render_blocks_to_pages(&blocks, &mut page_sink, options)?;
214    Ok(warnings)
215}
216
217fn select_mhtml_body(message: &Message<'_>) -> Result<(Option<MhtmlBodyPart>, bool, Vec<String>)> {
218    let mut related_found = false;
219    let mut warnings = Vec::new();
220
221    for related_part in &message.parts {
222        let Some(content_type) = related_part.content_type() else {
223            continue;
224        };
225        if !content_type.ctype().eq_ignore_ascii_case("multipart")
226            || !content_type
227                .subtype()
228                .is_some_and(|subtype| subtype.eq_ignore_ascii_case("related"))
229        {
230            continue;
231        }
232        related_found = true;
233        let children = related_part.sub_parts().unwrap_or_default();
234        if children.is_empty() {
235            warnings.push("MHTML multipart/related root has no body parts".into());
236            continue;
237        }
238
239        let root_id = if let Some(start) = mime_parameter(content_type, "start") {
240            let start = normalize_mhtml_content_id(start);
241            let matches = children
242                .iter()
243                .filter(|child_id| {
244                    message
245                        .parts
246                        .get(**child_id as usize)
247                        .and_then(|part| part.content_id())
248                        .is_some_and(|content_id| normalize_mhtml_content_id(content_id) == start)
249                })
250                .copied()
251                .collect::<Vec<_>>();
252            match matches.as_slice() {
253                [root_id] => *root_id,
254                [] => {
255                    return Err(Error::InvalidInput(format!(
256                        "MHTML multipart/related start parameter '{start}' does not match a direct body part"
257                    )));
258                }
259                _ => {
260                    return Err(Error::InvalidInput(format!(
261                        "MHTML multipart/related start parameter '{start}' is ambiguous"
262                    )));
263                }
264            }
265        } else {
266            children[0]
267        };
268
269        let root_part = message.parts.get(root_id as usize).ok_or_else(|| {
270            Error::InvalidInput("MHTML multipart/related root part index is invalid".into())
271        })?;
272        if let Some(expected_type) = mime_parameter(content_type, "type") {
273            let actual_type = root_part.content_type().map(|part_type| {
274                format!(
275                    "{}/{}",
276                    part_type.ctype(),
277                    part_type.subtype().unwrap_or_default()
278                )
279            });
280            if !actual_type
281                .as_deref()
282                .is_some_and(|actual| actual.eq_ignore_ascii_case(expected_type.trim()))
283            {
284                push_mhtml_warning_once(
285                    &mut warnings,
286                    "MHTML multipart/related type parameter does not match its root part",
287                );
288            }
289        } else {
290            push_mhtml_warning_once(
291                &mut warnings,
292                "MHTML multipart/related is missing its required type parameter",
293            );
294        }
295
296        let descendants = mhtml_descendants(message, root_id);
297        if let Some(body_part) = first_supported_body_in(message, &descendants) {
298            return Ok((Some(body_part), true, warnings));
299        }
300        push_mhtml_warning_once(
301            &mut warnings,
302            "MHTML multipart/related root contains no supported HTML/text body",
303        );
304        return Ok((None, true, warnings));
305    }
306
307    if !related_found {
308        if let Some(part_id) = message.html_body.first().copied() {
309            warnings.push(
310                "MHTML message has no multipart/related root; the first HTML body is used as a compatibility fallback".into(),
311            );
312            return Ok((Some(MhtmlBodyPart::Html { part_id }), false, warnings));
313        }
314        if let Some(part_id) = message.text_body.first().copied() {
315            warnings.push(
316                "MHTML message has no multipart/related root; the first text body is used as a compatibility fallback".into(),
317            );
318            return Ok((Some(MhtmlBodyPart::Text { part_id }), false, warnings));
319        }
320    }
321    Ok((None, related_found, warnings))
322}
323
324fn first_supported_body_in(
325    message: &Message<'_>,
326    descendants: &std::collections::HashSet<usize>,
327) -> Option<MhtmlBodyPart> {
328    if let Some((part_id, _)) = message
329        .parts
330        .iter()
331        .enumerate()
332        .find(|(part_id, part)| descendants.contains(part_id) && part.is_text_html())
333    {
334        return Some(MhtmlBodyPart::Html {
335            part_id: part_id as u32,
336        });
337    }
338    message
339        .parts
340        .iter()
341        .enumerate()
342        .find(|(part_id, part)| {
343            descendants.contains(part_id) && part.is_text() && !part.is_text_html()
344        })
345        .map(|(part_id, _)| MhtmlBodyPart::Text {
346            part_id: part_id as u32,
347        })
348}
349
350fn mhtml_descendants(message: &Message<'_>, root_id: u32) -> std::collections::HashSet<usize> {
351    let mut descendants = std::collections::HashSet::new();
352    let mut pending = vec![root_id as usize];
353    while let Some(part_id) = pending.pop() {
354        if !descendants.insert(part_id) {
355            continue;
356        }
357        if let Some(part) = message.parts.get(part_id)
358            && let PartType::Multipart(children) = &part.body
359        {
360            pending.extend(children.iter().map(|child_id| *child_id as usize));
361        }
362    }
363    descendants
364}
365
366fn mime_parameter<'a>(
367    content_type: &'a mail_parser::ContentType<'_>,
368    name: &str,
369) -> Option<&'a str> {
370    content_type
371        .attributes()?
372        .iter()
373        .find(|attribute| attribute.name.eq_ignore_ascii_case(name))
374        .map(|attribute| attribute.value.as_ref())
375}
376
377fn normalize_mhtml_content_id(value: &str) -> String {
378    value
379        .trim()
380        .trim_start_matches('<')
381        .trim_end_matches('>')
382        .to_owned()
383}
384
385fn push_mhtml_warning_once(warnings: &mut Vec<String>, warning: impl Into<String>) {
386    let warning = warning.into();
387    if !warnings.contains(&warning) {
388        warnings.push(warning);
389    }
390}
391
392pub(crate) fn image_dimensions(bytes: &[u8], mime: &str) -> Option<(u32, u32)> {
393    crate::document::mime_images::image_dimensions(bytes, mime)
394}
395
396fn clean_text(text: &str) -> String {
397    text.chars()
398        .map(|character| if character == '\r' { '\n' } else { character })
399        .filter(|character| matches!(character, '\n' | '\t') || !character.is_control())
400        .collect()
401}