1use 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}