1use mail_parser::{Address, MessageParser, MimeHeaders, PartType};
14use serde::Serialize;
15use std::collections::{HashMap, HashSet};
16use std::path::Path;
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
20pub struct Person {
21 pub name: Option<String>,
22 pub address: Option<String>,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
26pub struct Attachment {
27 pub index: u32,
29 pub name: String,
30 pub mime: String,
32 pub size: usize,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
37#[serde(tag = "kind", rename_all = "kebab-case")]
38pub enum Part {
39 Text { text: String },
41 Attribution { text: String },
43 Quote { depth: u8, text: String },
45 Headers { fields: Vec<(String, String)> },
47 Signature { text: String },
49}
50
51#[derive(Debug, Clone, Serialize)]
53pub struct Reading {
54 pub from: Vec<Person>,
55 pub to: Vec<Person>,
56 pub cc: Vec<Person>,
57 pub subject: String,
58 pub attachments: Vec<Attachment>,
59 pub parts: Vec<Part>,
60 pub html: Option<Html>,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
66pub struct Html {
67 pub main: String,
69 pub quoted: Option<String>,
71 pub images_hidden: bool,
73}
74
75pub fn read(path: &Path) -> Option<Reading> {
77 read_bytes(&std::fs::read(path).ok()?)
78}
79
80pub fn read_bytes(raw: &[u8]) -> Option<Reading> {
82 let message = MessageParser::default().parse(raw)?;
83 let text = message.body_text(0).map(|b| b.into_owned()).unwrap_or_default();
84 Some(Reading {
85 from: people(message.from()),
86 to: people(message.to()),
87 cc: people(message.cc()),
88 subject: message.subject().unwrap_or("").to_string(),
89 attachments: message
90 .attachments()
91 .enumerate()
92 .map(|(i, part)| Attachment {
93 index: i as u32,
94 name: file_name(part.attachment_name(), i),
95 mime: part.content_type().map_or_else(String::new, |t| format!("{}/{}", t.ctype(), t.subtype().unwrap_or("octet-stream"))),
96 size: part.contents().len(),
97 })
98 .collect(),
99 parts: parts(&text),
100 html: message.html_part(0).and_then(|part| match &part.body {
101 PartType::Html(html) => Some(safe_html(html)),
102 _ => None,
103 }),
104 })
105}
106
107const QUOTE_MARKERS: &[&str] = &[
110 "<div class=\"gmail_quote",
111 "<blockquote type=\"cite\"",
112 "<div class=\"moz-cite-prefix\"",
113 "<div id=\"divrplyfwdmsg\"",
114 "<div id=\"appendonsend\"",
115 "<hr id=\"stopspelling\"",
116 "border-top:solid #e1e1e1",
117 "border-top:solid #b5c4df",
118];
119
120pub fn safe_html(html: &str) -> Html {
122 let lower = html.to_ascii_lowercase();
124 let split = QUOTE_MARKERS
126 .iter()
127 .filter_map(|m| {
128 let at = lower.find(m)?;
129 if m.starts_with('<') { Some(at) } else { lower[..at].rfind('<') }
130 })
131 .min()
132 .filter(|at| html.is_char_boundary(*at));
133 let (main, quoted) = match split {
134 Some(at) if at > 0 => (&html[..at], Some(&html[at..])),
135 _ => (html, None),
136 };
137 Html {
138 main: clean_html(main),
139 quoted: quoted.map(clean_html).filter(|q| !q.trim().is_empty()),
140 images_hidden: lower.contains("<img"),
141 }
142}
143
144fn clean_html(html: &str) -> String {
148 let tags: HashSet<&str> = [
149 "a", "b", "strong", "i", "em", "u", "p", "br", "div", "span", "ul", "ol", "li", "blockquote", "pre", "code",
150 "hr", "table", "thead", "tbody", "tfoot", "tr", "td", "th", "small", "sup", "sub", "dl", "dt", "dd", "h1", "h2",
151 "h3", "h4", "h5", "h6",
152 ]
153 .into_iter()
154 .collect();
155 let removed: HashSet<&str> = ["style", "script", "head", "title"].into_iter().collect();
156 let attributes: HashMap<&str, HashSet<&str>> = [("a", ["href"].into_iter().collect())].into_iter().collect();
157 let cleaned = ammonia::Builder::empty()
158 .tags(tags)
159 .clean_content_tags(removed)
160 .tag_attributes(attributes)
161 .generic_attributes(HashSet::new())
163 .url_schemes(["http", "https", "mailto"].into_iter().collect())
164 .url_relative(ammonia::UrlRelative::Deny)
167 .link_rel(None)
168 .strip_comments(true)
169 .clean(&shallow(html))
170 .to_string();
171 let mut out = cleaned;
172 for (from, to) in [
174 ("<table>", "<div>"), ("</table>", "</div>"), ("<tr>", "<div>"), ("</tr>", "</div>"), ("<td>", "<div>"),
175 ("</td>", "</div>"), ("<th>", "<div><b>"), ("</th>", "</b></div>"), ("<tbody>", ""), ("</tbody>", ""),
176 ("<thead>", ""), ("</thead>", ""), ("<tfoot>", ""), ("</tfoot>", ""),
177 ] {
178 out = out.replace(from, to);
179 }
180 for level in 1..=6 {
181 out = out.replace(&format!("<h{level}>"), "<p><b>").replace(&format!("</h{level}>"), "</b></p>");
182 }
183 without_spacers(&out).trim().to_string()
184}
185
186const DEEPEST: usize = 512;
190
191const VOID: &[&str] = &[
193 "area", "base", "basefont", "bgsound", "br", "col", "embed", "frame", "hr", "image", "img", "input", "keygen", "link", "meta", "param",
194 "source", "track", "wbr",
195];
196
197const CLOSES_P: &[&str] = &[
199 "address", "article", "aside", "blockquote", "center", "details", "dialog", "dir", "div", "dl", "fieldset", "figcaption", "figure",
200 "footer", "header", "hgroup", "main", "menu", "nav", "ol", "p", "search", "section", "summary", "ul", "h1", "h2", "h3", "h4", "h5",
201 "h6", "pre", "listing", "xmp", "plaintext", "li", "dd", "dt", "hr",
202];
203
204const HEADINGS: &[&str] = &["h1", "h2", "h3", "h4", "h5", "h6"];
205
206fn closed_on_the_way(name: &str) -> &'static [&'static [&'static str]] {
211 match name {
212 "ul" | "ol" => &[&["p"], &["li"]],
213 "dl" => &[&["p"], &["dd", "dt"]],
214 "table" => &[&["p"], &["td", "th"], &["tr"], &["tbody", "thead", "tfoot"]],
215 "tbody" | "thead" | "tfoot" => &[&["p"], &["td", "th"], &["tr"]],
216 "tr" => &[&["p"], &["td", "th"]],
217 "address" | "article" | "aside" | "blockquote" | "center" | "details" | "dialog" | "dir" | "div" | "fieldset" | "figcaption"
218 | "figure" | "footer" | "header" | "hgroup" | "listing" | "main" | "menu" | "nav" | "pre" | "search" | "section" | "summary"
219 | "li" | "dd" | "dt" | "td" | "th" | "caption" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" => &[&["p"]],
220 _ => &[],
221 }
222}
223
224fn closed_before(name: &str) -> &'static [&'static [&'static str]] {
227 match name {
228 "li" => &[&["li"]],
229 "dd" | "dt" => &[&["dd", "dt"]],
230 "h1" | "h2" | "h3" | "h4" | "h5" | "h6" => &[HEADINGS],
231 "option" | "optgroup" => &[&["option"]],
232 "td" | "th" => &[&["td", "th"]],
233 "tr" => &[&["td", "th"], &["tr"]],
234 "tbody" | "thead" | "tfoot" => &[&["td", "th"], &["tr"], &["tbody", "thead", "tfoot"]],
235 _ => &[],
236 }
237}
238
239fn shallow(html: &str) -> std::borrow::Cow<'_, str> {
253 let mut open: Vec<String> = Vec::new();
254 let mut out: Option<String> = None;
256 let mut copied = 0;
257 let mut from = 0;
258 while let Some(lt) = html[from..].find('<').map(|at| from + at) {
259 let after = &html.as_bytes()[lt + 1..];
260 let (end, name_at) = match after {
261 [b'/', c, ..] if c.is_ascii_alphabetic() => (true, lt + 2),
262 [c, ..] if c.is_ascii_alphabetic() => (false, lt + 1),
263 _ => {
264 from = lt + 1;
265 continue;
266 }
267 };
268 let Some(gt) = html[name_at..].find('>').map(|at| name_at + at) else { break };
270 from = gt + 1;
271 let name_end = html[name_at..gt].find(|c: char| c.is_ascii_whitespace() || c == '/').map_or(gt, |at| name_at + at);
272 let name = html[name_at..name_end].to_ascii_lowercase();
273 if matches!(name.as_str(), "html" | "head" | "body") {
275 continue;
276 }
277 let mut keep = open.len();
278 let close = |sets: &[&[&str]], keep: &mut usize| {
279 for set in sets {
280 if *keep > 0 && set.contains(&open[*keep - 1].as_str()) {
281 *keep -= 1;
282 }
283 }
284 };
285 if end {
286 close(closed_on_the_way(&name), &mut keep);
287 if keep > 0 && open[keep - 1] == name {
288 open.truncate(keep - 1);
289 }
290 continue;
291 }
292 if CLOSES_P.contains(&name.as_str()) {
293 close(&[&["p"]], &mut keep);
294 }
295 close(closed_before(&name), &mut keep);
296 if VOID.contains(&name.as_str()) {
297 open.truncate(keep);
298 } else if keep < DEEPEST {
299 open.truncate(keep);
300 open.push(name);
301 } else {
302 let out = out.get_or_insert_with(|| String::with_capacity(html.len()));
303 out.push_str(&html[copied..lt]);
304 out.push(' ');
305 copied = gt + 1;
306 }
307 }
308 match out {
309 None => std::borrow::Cow::Borrowed(html),
310 Some(mut out) => {
311 out.push_str(&html[copied..]);
312 std::borrow::Cow::Owned(out)
313 }
314 }
315}
316
317fn without_spacers(html: &str) -> String {
324 const EMPTY: [&str; 6] = ["<div></div>", "<div> </div>", "<div> </div>", "<p></p>", "<p> </p>", "<span></span>"];
325 let mut out = String::with_capacity(html.len());
326 for piece in html.split_inclusive('>') {
328 out.push_str(piece);
329 if let Some(empty) = EMPTY.iter().find(|empty| out.ends_with(*empty)) {
330 out.truncate(out.len() - empty.len());
331 } else if out.ends_with("<br><br><br>") {
332 out.truncate(out.len() - "<br>".len());
333 }
334 }
335 out
336}
337
338pub fn linkify(text: &str) -> String {
341 let mut out = String::with_capacity(text.len() + 64);
342 for (i, line) in text.split('\n').enumerate() {
343 if i > 0 {
344 out.push_str("<br>");
345 }
346 let mut rest = line;
347 while let Some(at) = next_link(rest) {
348 out.push_str(&escape(&rest[..at]));
349 let url_len = rest[at..].find(|c: char| c.is_whitespace() || matches!(c, '<' | '>' | '"' | ')' | ']')).unwrap_or(rest.len() - at);
350 let url = rest[at..at + url_len].trim_end_matches(['.', ',', ';', ':', '!', '?']);
351 let site = url.split("://").nth(1).unwrap_or(url).split(['/', '?', '#']).next().unwrap_or(url);
352 let shown = if url.trim_end_matches('/').ends_with(site) { site.to_string() } else { format!("{site}/…") };
353 out.push_str(&format!("<a href=\"{}\">{}</a>", escape(url), escape(&shown)));
354 rest = &rest[at + url.len()..];
355 }
356 out.push_str(&escape(rest));
357 }
358 out
359}
360
361fn next_link(text: &str) -> Option<usize> {
365 text.match_indices("http").map(|(at, _)| at).find(|&at| {
366 let after = &text[at + "http".len()..];
367 after.starts_with("://") || after.starts_with("s://")
368 })
369}
370
371fn escape(text: &str) -> String {
372 text.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
373}
374
375pub fn calendar(path: &Path) -> Option<String> {
379 let raw = std::fs::read(path).ok()?;
380 let message = MessageParser::default().parse(&raw)?;
381 let mut found: Vec<String> = message
382 .parts
383 .iter()
384 .filter(|part| {
385 part.content_type().is_some_and(|t| {
386 let kind = format!("{}/{}", t.ctype(), t.subtype().unwrap_or("")).to_ascii_lowercase();
387 kind == "text/calendar" || kind == "application/ics"
388 })
389 })
390 .filter_map(|part| match &part.body {
391 mail_parser::PartType::Text(text) => Some(text.to_string()),
392 mail_parser::PartType::Binary(bytes) | mail_parser::PartType::InlineBinary(bytes) => String::from_utf8(bytes.to_vec()).ok(),
393 _ => None,
394 })
395 .filter(|text| text.contains("BEGIN:VCALENDAR"))
396 .collect();
397 found.sort_by_key(|text| !text.contains("METHOD:"));
398 found.into_iter().next()
399}
400
401pub fn attachment(path: &Path, index: u32) -> Option<(String, Vec<u8>)> {
402 attachment_from(&std::fs::read(path).ok()?, index)
403}
404
405pub fn attachment_from(raw: &[u8], index: u32) -> Option<(String, Vec<u8>)> {
407 let message = MessageParser::default().parse(raw)?;
408 let part = message.attachments().nth(index as usize)?;
409 Some((file_name(part.attachment_name(), index as usize), part.contents().to_vec()))
410}
411
412fn people(address: Option<&Address>) -> Vec<Person> {
413 address.map_or_else(Vec::new, |a| {
414 a.iter().map(|p| Person { name: p.name().map(str::to_string), address: p.address().map(str::to_string) }).collect()
415 })
416}
417
418fn file_name(name: Option<&str>, index: usize) -> String {
424 let base = name.and_then(|n| n.rsplit(['/', '\\']).next()).unwrap_or("").trim();
425 let clean: String = base.chars().filter(|c| !c.is_control()).map(|c| if "<>:\"|?*".contains(c) { '_' } else { c }).collect();
426 let clean = clean.trim_start_matches('.').trim().trim_end_matches(['.', ' ']);
427 if clean.is_empty() {
428 return format!("attachment-{}", index + 1);
429 }
430 let stem = clean.split('.').next().unwrap_or(clean).trim_end().to_ascii_uppercase();
431 let device = matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL") || (stem.len() == 4 && (stem.starts_with("COM") || stem.starts_with("LPT")) && stem.as_bytes()[3].is_ascii_digit());
432 if device { format!("_{clean}") } else { clean.to_string() }
433}
434
435pub fn parts(text: &str) -> Vec<Part> {
438 parts_with(&crate::words::current().quotes, text)
439}
440
441struct Folded {
443 openings: Vec<String>,
444 header_names: Vec<String>,
445 from_names: Vec<String>,
446 enough_names: Vec<String>,
447 wrote: Vec<String>,
448}
449
450impl Folded {
451 fn of(q: &crate::words::Quotes) -> Folded {
452 let fold = |list: &[String]| list.iter().map(|w| crate::words::folded(w)).filter(|w| !w.is_empty()).collect();
453 Folded { openings: fold(&q.openings), header_names: fold(&q.header_names), from_names: fold(&q.from_names), enough_names: fold(&q.enough_names), wrote: fold(&q.wrote) }
454 }
455}
456
457struct Ahead {
463 fields: Vec<Option<(String, String)>>,
464 run_end: Vec<usize>,
465 enough: Vec<bool>,
466 next_filled: Vec<Option<usize>>,
467}
468
469impl Ahead {
470 fn of(words: &Folded, lines: &[&str]) -> Ahead {
471 let n = lines.len();
472 let fields: Vec<Option<(String, String)>> = lines.iter().map(|line| header_field(words, line)).collect();
473 let (mut run_end, mut enough, mut next_filled) = (vec![n; n + 1], vec![false; n + 1], vec![None; n + 1]);
474 let mut filled = None;
475 for i in (0..n).rev() {
476 if let Some((name, _)) = &fields[i] {
477 run_end[i] = run_end[i + 1];
478 enough[i] = enough[i + 1] || words.enough_names.contains(&crate::words::folded(name));
479 } else {
480 run_end[i] = i;
481 }
482 next_filled[i] = filled;
483 if !lines[i].trim().is_empty() {
484 filled = Some(i);
485 }
486 }
487 Ahead { fields, run_end, enough, next_filled }
488 }
489
490 fn header_block_at(&self, words: &Folded, i: usize) -> Option<(Vec<(String, String)>, usize)> {
494 let (name, _) = self.fields.get(i)?.as_ref()?;
495 let end = self.run_end[i];
496 (words.from_names.contains(&crate::words::folded(name)) && end - i >= 2 && self.enough[i]).then(|| (self.fields[i..end].iter().flatten().cloned().collect(), end))
497 }
498}
499
500pub fn parts_with(q: &crate::words::Quotes, text: &str) -> Vec<Part> {
506 let words = Folded::of(q);
507 let lines: Vec<&str> = text.lines().collect();
508 let ahead = Ahead::of(&words, &lines);
509 let mut parts: Vec<Part> = Vec::new();
510 let mut i = 0;
511 let mut quoted_below = 0u8;
513 while i < lines.len() {
514 let line = lines[i];
515 let lower = line.trim().to_lowercase();
516 let folded = crate::words::folded(line);
517 if words.openings.iter().any(|o| folded.starts_with(o.as_str())) || is_separator(&lower) && ahead.header_block_at(&words, i + 1).is_some() {
518 i += 1;
519 continue;
520 }
521 if let Some((fields, next)) = ahead.header_block_at(&words, i) {
522 parts.push(Part::Headers { fields });
523 quoted_below = quoted_below.saturating_add(1);
524 i = next;
525 continue;
526 }
527 if is_attribution(&words, &folded, ahead.next_filled[i].map(|j| lines[j])) {
528 take_previous_line_into_attribution(q, &mut parts, line);
529 i += 1;
530 continue;
531 }
532 if line == "-- " || line == "--" {
533 push(&mut parts, Part::Signature { text: String::new() });
534 i += 1;
535 continue;
536 }
537 let (depth, content) = quote_depth(line);
538 let depth = depth.saturating_add(quoted_below);
539 let in_signature = matches!(parts.last(), Some(Part::Signature { .. })) && depth == 0;
540 let part = if in_signature {
541 Part::Signature { text: content.to_string() }
542 } else if depth > 0 {
543 Part::Quote { depth, text: content.to_string() }
544 } else {
545 Part::Text { text: content.to_string() }
546 };
547 push(&mut parts, part);
548 i += 1;
549 }
550 parts.into_iter().filter_map(tidy).collect()
551}
552
553fn push(parts: &mut Vec<Part>, part: Part) {
555 match (parts.last_mut(), part) {
556 (Some(Part::Text { text }), Part::Text { text: more })
557 | (Some(Part::Signature { text }), Part::Signature { text: more }) => {
558 text.push('\n');
559 text.push_str(&more);
560 }
561 (Some(Part::Quote { depth, text }), Part::Quote { depth: d, text: more }) if *depth == d => {
562 text.push('\n');
563 text.push_str(&more);
564 }
565 (_, part) => parts.push(part),
566 }
567}
568
569fn tidy(part: Part) -> Option<Part> {
571 let trim = |t: String| t.trim_matches('\n').trim_end().to_string();
572 let part = match part {
573 Part::Text { text } => Part::Text { text: trim(text) },
574 Part::Signature { text } => Part::Signature { text: trim(text) },
575 Part::Quote { depth, text } => Part::Quote { depth, text: trim(text) },
576 other => other,
577 };
578 let empty = match &part {
579 Part::Text { text } | Part::Signature { text } | Part::Quote { text, .. } | Part::Attribution { text } => text.trim().is_empty(),
580 Part::Headers { fields } => fields.is_empty(),
581 };
582 (!empty).then_some(part)
583}
584
585fn quote_depth(line: &str) -> (u8, &str) {
587 let mut depth = 0u8;
588 let mut rest = line;
589 loop {
590 let trimmed = rest.trim_start();
591 match trimmed.strip_prefix('>') {
592 Some(after) => {
593 depth = depth.saturating_add(1);
594 rest = after;
595 }
596 None => break,
597 }
598 }
599 (depth, if depth > 0 { rest.strip_prefix(' ').unwrap_or(rest) } else { line })
600}
601
602fn is_separator(lower: &str) -> bool {
604 lower.len() >= 10 && lower.chars().all(|c| c == '_' || c == '-')
605}
606
607fn header_field(words: &Folded, line: &str) -> Option<(String, String)> {
609 let (name, value) = line.split_once(':')?;
610 let key = crate::words::folded(name.trim().trim_start_matches(['*', '>', ' ']));
611 words.header_names.contains(&key).then(|| (name.trim().trim_matches('*').trim().to_string(), value.trim().to_string()))
612}
613
614fn is_attribution(words: &Folded, folded: &str, next: Option<&str>) -> bool {
617 words.wrote.iter().any(|e| folded.ends_with(e.as_str())) && next.is_some_and(|l| quote_depth(l).0 > 0)
618}
619
620fn take_previous_line_into_attribution(q: &crate::words::Quotes, parts: &mut Vec<Part>, line: &str) {
623 let mut attribution = line.trim().to_string();
624 if let Some(Part::Text { text } | Part::Signature { text }) = parts.last_mut() {
625 let trimmed = text.trim_end_matches('\n');
626 let (before, last) = match trimmed.rsplit_once('\n') {
627 Some((before, last)) => (before.to_string(), last.to_string()),
628 None => (String::new(), trimmed.to_string()),
629 };
630 let starts = q.attribution_starts.iter().map(|s| s.trim()).any(|s| !s.is_empty() && last.starts_with(&format!("{s} ")));
631 if starts && !last.trim_end().ends_with('.') {
632 attribution = format!("{} {attribution}", last.trim());
633 *text = before;
634 }
635 }
636 parts.push(Part::Attribution { text: attribution });
637}
638
639#[cfg(test)]
640mod tests {
641 use super::*;
642
643 fn kinds(parts: &[Part]) -> Vec<String> {
644 parts
645 .iter()
646 .map(|p| match p {
647 Part::Text { .. } => "text".to_string(),
648 Part::Attribution { .. } => "attribution".to_string(),
649 Part::Quote { depth, .. } => format!("quote{depth}"),
650 Part::Headers { .. } => "headers".to_string(),
651 Part::Signature { .. } => "signature".to_string(),
652 })
653 .collect()
654 }
655
656 #[test]
657 fn a_reply_with_quotes_and_a_signature() {
658 let text = "Merci, c'est noté.\n\n-- \nJean\n\nLe jeu. 1 oct. 2026 à 12:03, Marie Exemple <\nmarie@example.org> a écrit :\n> Bonjour,\n> Voici le document.\n>> Plus ancien.\n";
659 let reply = parts(text);
660 assert_eq!(kinds(&reply), ["text", "signature", "attribution", "quote1", "quote2"]);
661 assert_eq!(reply[0], Part::Text { text: "Merci, c'est noté.".into() });
662 assert_eq!(reply[1], Part::Signature { text: "Jean".into() });
663 assert_eq!(reply[2], Part::Attribution { text: "Le jeu. 1 oct. 2026 à 12:03, Marie Exemple < marie@example.org> a écrit :".into() });
664 assert_eq!(reply[3], Part::Quote { depth: 1, text: "Bonjour,\nVoici le document.".into() });
665 }
666
667 #[test]
668 fn outlook_answers_and_forwards() {
669 let outlook = "Bonjour,\nCi-joint.\n\n________________________________\nDe : Marie Exemple <marie@example.org>\nEnvoyé : jeudi 1 octobre 2026 12:03\nÀ : Jean <jean@example.org>\nObjet : RE: Dossier\n\nBonjour Jean,\nLe dossier est complet.\n";
670 let answer = parts(outlook);
671 assert_eq!(kinds(&answer), ["text", "headers", "quote1"]);
672 let Part::Headers { fields } = &answer[1] else { panic!() };
673 assert_eq!(fields[0], ("De".to_string(), "Marie Exemple <marie@example.org>".to_string()));
674 assert_eq!(fields.len(), 4);
675 let forward = "Pour info.\n\n---------- Forwarded message ---------\nFrom: Shop <shop@example.org>\nDate: Thu, 1 Oct 2026\nSubject: Order\nTo: <jean@example.org>\n\nYour order is confirmed.\n";
676 assert_eq!(kinds(&parts(forward)), ["text", "headers", "quote1"]);
677 let german = "Siehe unten.\n\n---------- Weitergeleitete Nachricht ---------\nVon: Shop <shop@example.org>\nDatum: Do., 1. Okt. 2026\nBetreff: Bestellung\nAn: <jean@example.org>\n\nIhre Bestellung ist bestätigt.\n";
679 let builtin = crate::words::Words::builtin();
680 assert_eq!(kinds(&parts_with(&builtin.quotes, german)), ["text"]);
681 let mut q = builtin.quotes.clone();
682 q.openings.push("---------- Weitergeleitete Nachricht ---------".into());
683 q.header_names.extend(["Von", "Datum", "Betreff", "An"].map(String::from));
684 q.from_names.push("Von".into());
685 q.enough_names.extend(["Betreff", "Datum"].map(String::from));
686 assert_eq!(kinds(&parts_with(&q, german)), ["text", "headers", "quote1"]);
687 }
688
689 #[test]
690 fn html_is_made_safe_and_its_history_folded() {
691 let html = "<html><head><style>p{color:red}</style><title>x</title></head><body>\
692 <table><tr><td><h1>Hello</h1><p style=\"color:red\">Read <a href=\"https://shop.example/x?t=1\" onclick=\"evil()\">this</a>.</p>\
693 <img src=\"https://tracker.example/pixel.gif\"><script>alert(1)</script><form><input></form></td></tr></table>\
694 <div class=\"gmail_quote\">On Thu, Jane wrote:<blockquote>Old text</blockquote></div></body></html>";
695 let safe = safe_html(html);
696 assert!(safe.images_hidden);
697 for gone in ["<img", "tracker", "<script", "alert", "style", "onclick", "<form", "<input", "<table", "<td", "<h1", "color"] {
698 assert!(!safe.main.contains(gone), "{gone} in {}", safe.main);
699 }
700 assert!(safe.main.contains("<p><b>Hello</b></p>"), "{}", safe.main);
701 assert!(safe.main.contains("<a href=\"https://shop.example/x?t=1\">this</a>"), "{}", safe.main);
702 let quoted = safe.quoted.unwrap();
703 assert!(quoted.contains("Old text") && !safe.main.contains("Old text"));
704 assert!(!clean_html("<a href=\"javascript:alert(1)\">x</a>").contains("javascript"));
705 for relative in ["../../Downloads/run.desktop", "//host.example/share/x", "x.sh"] {
707 assert!(!clean_html(&format!("<a href=\"{relative}\">x</a>")).contains("href"), "{relative}");
708 }
709 assert_eq!(clean_html("<table title=\"t\" lang=\"fr\"><tr><td>x</td></tr></table>"), "<div><div><div>x</div></div></div>");
710 }
711
712 #[test]
713 fn plain_links_show_their_site() {
714 assert_eq!(
715 linkify("See https://www.example.org/very/long?tracking=1, then <reply>.\nThanks"),
716 "See <a href=\"https://www.example.org/very/long?tracking=1\">www.example.org/…</a>, then <reply>.<br>Thanks"
717 );
718 assert_eq!(linkify("https://example.org/"), "<a href=\"https://example.org/\">example.org</a>");
719 }
720
721 #[test]
722 fn plain_text_stays_text() {
723 assert_eq!(kinds(&parts("Hello.\nDe toute façon: rien.\nSee you.")), ["text"]);
724 assert_eq!(file_name(Some("../../etc/passwd"), 0), "passwd");
725 assert_eq!(file_name(Some(".hidden"), 2), "hidden");
726 assert_eq!(file_name(None, 2), "attachment-3");
727 assert_eq!(file_name(Some("report.pdf:stream"), 0), "report.pdf_stream");
729 assert_eq!(file_name(Some("a<b>?\"*|.pdf"), 0), "a_b_____.pdf");
730 assert_eq!(file_name(Some("nul.txt"), 0), "_nul.txt");
731 assert_eq!(file_name(Some("COM1"), 0), "_COM1");
732 assert_eq!(file_name(Some("scan.pdf. . "), 0), "scan.pdf");
733 assert_eq!(file_name(Some("Console.pdf"), 0), "Console.pdf");
734 }
735
736 #[test]
737 fn spacers_go_in_one_pass() {
738 assert_eq!(without_spacers("<div><p></p><span></span> </div>x"), "x");
739 assert_eq!(without_spacers("<div><div> </div></div><p> </p>"), "");
740 assert_eq!(without_spacers("a<br><br><br><br><br>b"), "a<br><br>b");
741 assert_eq!(without_spacers("<br><div></div><br><br>"), "<br><br>");
742 assert_eq!(without_spacers("<div>x</div><div> </div><p>y</p>"), "<div>x</div><div> </div><p>y</p>");
743 assert_eq!(without_spacers("<div><br><br><br></div>"), "<div><br><br></div>");
745 assert_eq!(without_spacers("x<span></span>y"), "xy");
746 }
747
748 fn bound() -> std::time::Duration {
753 std::time::Duration::from_secs(if cfg!(debug_assertions) { 20 } else { 2 })
754 }
755
756 fn timed<T>(what: &str, f: impl FnOnce() -> T) -> T {
757 let start = std::time::Instant::now();
758 let out = f();
759 let took = start.elapsed();
760 eprintln!("{what}: {took:?}");
761 assert!(took < bound(), "{what}: {took:?}, more than {:?}", bound());
762 out
763 }
764
765 #[test]
766 fn hostile_html_reads_in_linear_time() {
767 let n = 16_000;
769 let html = format!("<html><body>{}{}</body></html>", "<div>".repeat(n), "</div>".repeat(n));
770 let safe = timed("nested empty blocks", || safe_html(&html));
771 assert!(safe.main.matches("<div>").count() <= DEEPEST, "{}", safe.main.len());
772 let raw = format!("From: a@example.org\r\nTo: b@example.org\r\nSubject: hi\r\nMIME-Version: 1.0\r\nContent-Type: text/html; charset=utf-8\r\n\r\n{html}\r\n");
774 let reading = timed("a message of nested empty blocks", || read_bytes(raw.as_bytes())).unwrap();
775 assert!(reading.html.is_some_and(|h| h.main.matches("<div>").count() <= DEEPEST));
776 let n = 40_000;
780 for (what, hostile) in [
781 ("ignored end tags", "<div></span>".repeat(n)),
782 ("blocks around an ignored end", format!("{}<object>{}", "<div>".repeat(400), "</div>".repeat(400)).repeat(n / 800)),
783 ("cells outside a table", "<td><li><dd></td>".repeat(n)),
784 ("a list closed by a list it is not in", "<select><ul></select><li><dd></ul>".repeat(n / 4)),
785 ("formatting", "<b id=x1><i id=x2><p></b>x".repeat(n / 2)),
786 ("paragraphs in buttons", "<button><p>".repeat(n)),
787 ("self-closed blocks", "<div/>".repeat(n)),
788 ("end tags with nothing to close, deep", format!("{}{}", "<div>".repeat(600), "</p>".repeat(n))),
789 ] {
790 let safe = timed(what, || safe_html(&hostile));
791 assert!(safe.main.len() <= hostile.len(), "{what}");
792 }
793 }
794
795 #[test]
796 fn deep_html_is_cut_and_mail_is_not() {
797 let sloppy = "<ul><li>a<li>b</ul><p>c<p>d<div><p>e</div><table><tr><td>f<td>g<tr><td>h</table><dl><dt>i<dd>j</dl><p>k<hr>l<br>".repeat(2_000);
799 assert!(matches!(shallow(&sloppy), std::borrow::Cow::Borrowed(_)));
800 let nested = format!("{}x{}", "<table><tbody><tr><td><div><p>".repeat(60), "</p></div></td></tr></tbody></table>".repeat(60));
801 assert!(matches!(shallow(&nested), std::borrow::Cow::Borrowed(_)));
802 let deep = format!("{}x{}", "<div class=\"a\">".repeat(1_000), "</div>".repeat(1_000));
804 let cut = shallow(&deep);
805 assert_eq!(cut.matches("<div").count(), DEEPEST);
806 assert!(cut.contains(" x</div>") && cut.matches("</div>").count() == 1_000);
807 let tricky = format!("{}<<div>img src=x>", "<div>".repeat(DEEPEST));
809 assert!(shallow(&tricky).ends_with("< img src=x>"));
810 }
811
812 #[test]
813 fn hostile_plain_text_reads_in_linear_time() {
814 let headers = "From: x\n".repeat(8_000);
816 let read = timed("header lines", || parts(&headers));
817 assert!(!read.is_empty());
818 let blanks = format!("x{}", "\n".repeat(160_000));
820 assert_eq!(timed("blank lines", || parts(&blanks)), [Part::Text { text: "x".into() }]);
821 let attributions = format!("{}\n> quoted", "Jean wrote:\n\n\n\n\n\n\n\n".repeat(8_000));
823 assert!(timed("attributions", || parts(&attributions)).iter().any(|p| matches!(p, Part::Quote { .. })));
824 let block = format!("{headers}Subject: y\n\nText.\n");
826 assert!(timed("header lines and a subject", || parts(&block)).iter().any(|p| matches!(p, Part::Headers { fields } if fields.len() == 8_001)));
827 let links = "https://example.org/a ".repeat(40_000);
829 assert_eq!(timed("a line of links", || linkify(&links)).matches("<a href=").count(), 40_000);
830 }
831}