Skip to main content

sioul_core/
reading.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! A message laid out for reading: who wrote to whom, the attachments apart,
5//! and the text cut into what this message says, the messages it quotes, the
6//! headers of the messages it forwards, and the signature.
7//!
8//! HTML mail is shown formatted once made safe (`safe_html`): only text,
9//! structure and links survive; images, styles, scripts and anything that
10//! would load from the network are dropped, so nothing remote loads and
11//! nothing runs. Plain text keeps its parts, its links made clickable.
12
13use mail_parser::{Address, MessageParser, MimeHeaders, PartType};
14use serde::Serialize;
15use std::collections::{HashMap, HashSet};
16use std::path::Path;
17
18/// One person, as a header names them.
19#[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    /// Its place among the message's attachments, to open it.
28    pub index: u32,
29    pub name: String,
30    /// "application/pdf".
31    pub mime: String,
32    pub size: usize,
33}
34
35/// A piece of a message's text.
36#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
37#[serde(tag = "kind", rename_all = "kebab-case")]
38pub enum Part {
39    /// What this message says.
40    Text { text: String },
41    /// "Le 1 oct. 2026, Jean a écrit :", above what it quotes.
42    Attribution { text: String },
43    /// A quoted message; `depth` 1 is the message answered, 2 the one before it.
44    Quote { depth: u8, text: String },
45    /// The headers of a forwarded or answered message, as the text repeats them.
46    Headers { fields: Vec<(String, String)> },
47    /// After "-- ".
48    Signature { text: String },
49}
50
51/// A message, laid out for reading.
52#[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    /// The HTML version, made safe, when the message has one.
61    pub html: Option<Html>,
62}
63
64/// A message's HTML, made safe and calm to read.
65#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
66pub struct Html {
67    /// What this message says.
68    pub main: String,
69    /// The messages it answers or forwards, to fold.
70    pub quoted: Option<String>,
71    /// It had images: none is shown.
72    pub images_hidden: bool,
73}
74
75/// Reads a message file for the reading pane.
76pub fn read(path: &Path) -> Option<Reading> {
77    read_bytes(&std::fs::read(path).ok()?)
78}
79
80/// A message laid out for reading, from its bytes (a decrypted message lives only in memory).
81pub 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
107/// Where the messages a reply quotes or a forward carries begin, in the HTML of
108/// Gmail, Apple Mail and Thunderbird, and Outlook.
109const 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
120/// A message's HTML, made safe: the quoted history split off, then each part cleaned.
121pub fn safe_html(html: &str) -> Html {
122    // ASCII lowercase keeps every byte where it was, so positions hold in `html`.
123    let lower = html.to_ascii_lowercase();
124    // A marker that is a tag splits where it starts; a style splits at the start of its tag.
125    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
144/// Only text, structure and links: no images, styles, scripts, forms or frames,
145/// no attribute but a link's address, and only web and mail links. Layout
146/// tables become plain blocks, headings bold paragraphs, empty blocks go.
147fn 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        // ammonia keeps "lang" and "title" everywhere unless told otherwise.
162        .generic_attributes(HashSet::new())
163        .url_schemes(["http", "https", "mailto"].into_iter().collect())
164        // A relative link has no page to be relative to in a mail: opened, it would
165        // name a file of this computer ("../Downloads/x") or a share ("//host/x").
166        .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    // Tables laid out a page, not data: one block after another reads better.
173    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
186/// How deep elements may stand in one another in a message's HTML before the
187/// deeper ones are left out, their words kept (`shallow`). Mail nests a few
188/// dozen levels; web browsers stop at 512 too.
189const DEEPEST: usize = 512;
190
191/// Elements that hold nothing: never left open.
192const 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
197/// The start tags before which the parser closes an open paragraph.
198const 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
206/// The end tags that close what the parser closes for them on the way, and
207/// what, from the innermost out: a paragraph before a block's end, a list
208/// item before its list's, a cell and a row before their table's. Any other
209/// end tag closes the innermost element only, when it is the one it names.
210fn 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
224/// What a start tag closes first when it is innermost, from the innermost
225/// out: the next list item closes the last, the next cell the last.
226fn 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
239/// The HTML with no element deeper than `DEEPEST`, in one pass. The parser
240/// under ammonia looks through the elements still open at most tags, so
241/// elements nested by the thousand cost it the square of their number
242/// (32,000 empty blocks in one another: 3 seconds; a megabyte of them,
243/// minutes). This follows the elements the parser keeps open without ever
244/// counting one closed before the parser closes it: an element closes on
245/// its own end tag when it is the innermost, or when the parser surely
246/// closes it then (`closed_on_the_way`, `closed_before`). Sloppy mail at
247/// worst seems deeper than it is; the parser, which also opens elements of
248/// its own (a table's body and row), never holds more than a few times what
249/// is counted. A start tag past the depth is left out, a space in
250/// its place, so that no word runs into the next and no "<" before it starts
251/// a tag; its words stay.
252fn shallow(html: &str) -> std::borrow::Cow<'_, str> {
253    let mut open: Vec<String> = Vec::new();
254    // Only once a tag is left out: what came before it, copied.
255    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        // A tag never ended is words, for the parser too.
269        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        // The parser keeps one of each, whatever is written.
274        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
317/// Spacer blocks and runs of line breaks, which layouts use for room, taken
318/// out in one pass: a block is dropped as its end is written, when nothing
319/// but a space stands in it, so a block left empty by the spacers it held goes
320/// too (`<div><p></p></div>`), and a third line break in a row goes. Done by
321/// replacing them over the whole text until none was left, the work grew with
322/// the square of the nesting: 8,000 empty blocks in one another took seconds.
323fn without_spacers(html: &str) -> String {
324    const EMPTY: [&str; 6] = ["<div></div>", "<div>&nbsp;</div>", "<div> </div>", "<p></p>", "<p>&nbsp;</p>", "<span></span>"];
325    let mut out = String::with_capacity(html.len());
326    // Each piece ends with a ">", the only place a spacer can end.
327    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
338/// Plain text as HTML: escaped, its web links clickable and shown by their site
339/// ("example.org/…"), its lines kept.
340pub 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
361/// Where the next web address starts: "http://" or "https://". One search
362/// for both: searched apart, a line of a thousand "https://" links had its
363/// whole rest read again for an "http://" before each of them.
364fn 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('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
373}
374
375/// One attachment's name and bytes, to open or save it.
376/// The iCalendar a message carries, inline or attached (an invitation, RFC 6047):
377/// the part with a METHOD first, as Gmail and Outlook send both.
378pub 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
405/// An attachment from a message's bytes: its name and content.
406pub 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
418/// A name safe to write in a folder, on every system, since what is saved may
419/// be synced to another: no path, no control characters, none of the
420/// characters Windows forbids (`"report.pdf:x"` would write a hidden stream
421/// there), no dot or space at the end, and no device's name ("nul.txt"); the
422/// extension kept.
423fn 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
435/// Cuts a message's text into parts (see `Part`), its quoted history read
436/// with the words in use (`words::current`).
437pub fn parts(text: &str) -> Vec<Part> {
438    parts_with(&crate::words::current().quotes, text)
439}
440
441/// The words of `words::Quotes`, folded once for a whole text, not once per line.
442struct 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
457/// What `parts_with` looks ahead for, read once from the end of the text: each
458/// line's header field, where each run of header fields ends and whether it
459/// names a subject or a date, and the next line that is not blank. Looked for
460/// again from every line, they made the work grow with the square of the
461/// lines: 8,000 lines of "From: x" took half a minute.
462struct 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    /// A block of repeated headers starting at `i`: at least a sender and a
491    /// subject or a date among consecutive "Name: value" lines. Returns them
492    /// and the line after.
493    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
500/// Cuts a message's text into parts with these words (`words::Quotes`): the
501/// lines that open a forwarded or answered message, the header names of a
502/// repeated header block, the endings of an attribution, in any language
503/// your correspondents' mail programs write them. Each line is read a set
504/// number of times, whatever comes after it (`Ahead`).
505pub 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    // After the headers of a message answered or forwarded, the rest is that message.
512    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
553/// Adds a line to the last part when it is of the same kind, else starts a new part.
554fn 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
569/// Trims blank lines around a part; empty parts go.
570fn 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
585/// "> > text" → (2, "text").
586fn 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
602/// Outlook's line of underscores before "De :".
603fn is_separator(lower: &str) -> bool {
604    lower.len() >= 10 && lower.chars().all(|c| c == '_' || c == '-')
605}
606
607/// "Name: value", with a header name, possibly with a space before the colon.
608fn 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
614/// "On Thu, 1 Oct 2026, Jean wrote:" or "Le jeu. 1 oct. 2026, Jean a écrit :"
615/// (`folded`), right before a quote (`next`, the next line that is not blank).
616fn 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
620/// Gmail cuts long attributions in two: "Le jeu. 1 oct. 2026 à 12:03, Jean <" and
621/// "jean@example.org> a écrit :". The first half is taken back from the text.
622fn 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        // A German mail program's forward, once its words are added.
678        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        // No link to a file or a share of this computer, no attribute but the address.
706        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 &lt;reply&gt;.<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        // A name every system can hold, its extension kept.
728        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>&nbsp;</div></div><p>&nbsp;</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        // A spacer split by the end of a run of breaks, and one with words around it.
744        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    /// How long a pass may take over one of the texts below (64 to 176 KB, made
749    /// to cost the most): linear, it takes milliseconds; quadratic, as it was,
750    /// from 10 seconds to a minute. Wide, for a busy machine, and wider for a
751    /// debug build.
752    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        // Empty blocks nested in one another: each pass of the old spacer loop took one level off.
768        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        // The same through the reader's entry point, as a message.
773        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        // Ways to keep elements open in the parser that a count of start and
777        // end tags would think closed: end tags it ignores, cells outside a
778        // table, items in one another, formatting it opens again.
779        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        // Mail as mail programs write it, sloppy ones too: every element kept.
798        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        // Deeper than `DEEPEST`, start tags go, their words and the end tags stay.
803        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        // A "<" left before a tag taken out never starts another.
808        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        // Lines that each start a header block: the old scan read every line after each.
815        let headers = "From: x\n".repeat(8_000);
816        let read = timed("header lines", || parts(&headers));
817        assert!(!read.is_empty());
818        // One word, then blank lines: each looked for the next line that is not blank.
819        let blanks = format!("x{}", "\n".repeat(160_000));
820        assert_eq!(timed("blank lines", || parts(&blanks)), [Part::Text { text: "x".into() }]);
821        // Attributions, each before many blank lines and a quote.
822        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        // A real header block among them is still one.
825        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        // A line of links: each looked for "http://" over the rest of the line.
828        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}