Skip to main content

rmut_core/
message.rs

1use std::fs;
2use std::path::Path;
3
4use anyhow::{Context, Result};
5use chrono::{Local, TimeZone};
6use mailparse::{MailHeaderMap, ParsedMail, parse_mail};
7
8use crate::maildir::MailFile;
9
10/// Summary of one message for the index view.
11#[derive(Debug, Clone)]
12pub struct Envelope {
13    pub file: MailFile,
14    /// Short display form of the sender (name when there is one).
15    pub from: String,
16    /// The whole decoded From header, so `~f` can match the address
17    /// too, like mutt (empty in entries from a pre-1.24 header cache).
18    pub from_full: String,
19    pub subject: String,
20    /// Unix epoch seconds from the Date header, 0 if missing/unparsable.
21    pub date: i64,
22    /// Normalized `<...>` Message-ID, if present.
23    pub msg_id: Option<String>,
24    /// References chain (oldest first), In-Reply-To appended if novel.
25    pub references: Vec<String>,
26    /// Runtime tag mark (mutt's `t`); never persisted.
27    pub tagged: bool,
28    /// Bare lowercase To / Cc addresses, for the "addressed to me"
29    /// index mark.
30    pub to: Vec<String>,
31    pub cc: Vec<String>,
32    /// Body line count for `%l`; None for header-only IMAP cache
33    /// files, whose `%?l?…&…?` else branch shows until the body is
34    /// fetched.
35    pub lines: Option<usize>,
36    /// Mailing-list name from List-Id, for `%L` ("To <name>").
37    pub list: Option<String>,
38    /// mutt's X-Label header, for `%y`, `~y`, and sort by label.
39    pub label: Option<String>,
40    /// The user broke this message out of its thread: rmut's own
41    /// `X-Rmut-Thread: broken`, written by break-thread. mutt has
42    /// nothing like it and its subject grouping hangs a broken
43    /// message straight back where it was; rmut's break-thread
44    /// sticks instead, and this is the header that makes it.
45    pub broken: bool,
46}
47
48/// The header break-thread leaves behind, so that the subject
49/// grouping knows to leave the message alone.
50pub const BROKEN_HEADER: &str = "X-Rmut-Thread";
51const BROKEN_VALUE: &str = "broken";
52
53/// Header text on its way to a one-line slot in the display. A tab
54/// or a stray control character would be written to the terminal as
55/// it stands: a tab jumps to the next tab stop, pushing the rest of
56/// an index row past the window edge and wrapping it onto a second
57/// line. Mail carries them often enough (a header the sender folded
58/// by hand, an RFC 2047 word that decoded to one), so every such
59/// field passes through here.
60pub fn one_line(text: &str) -> String {
61    text.chars()
62        .map(|c| match c.is_control() {
63            true => ' ',
64            false => c,
65        })
66        .collect()
67}
68
69pub fn envelope(file: MailFile) -> Result<Envelope> {
70    let raw = fs::read(&file.path).with_context(|| format!("reading {}", file.path.display()))?;
71    let mail = parse_mail(&raw).with_context(|| format!("parsing {}", file.path.display()))?;
72    let headers = mail.get_headers();
73    let from_full = one_line(&headers.get_first_value("From").unwrap_or_default());
74    let from = if from_full.trim().is_empty() {
75        "(unknown)".into()
76    } else {
77        short_from(&from_full)
78    };
79    let subject = headers
80        .get_first_value("Subject")
81        .map(|s| one_line(&s))
82        .filter(|s| !s.trim().is_empty())
83        .unwrap_or_else(|| "(no subject)".into());
84    let date = headers
85        .get_first_value("Date")
86        .and_then(|d| mailparse::dateparse(&d).ok())
87        .unwrap_or(0);
88    let msg_id = headers
89        .get_first_value("Message-ID")
90        .and_then(|v| parse_msg_ids(&v).into_iter().next());
91    let to = field_addresses(&headers.get_all_values("To").join(", "));
92    let cc = field_addresses(&headers.get_all_values("Cc").join(", "));
93    let mut references = headers
94        .get_first_value("References")
95        .map(|v| parse_msg_ids(&v))
96        .unwrap_or_default();
97    if let Some(irt) = headers.get_first_value("In-Reply-To")
98        && let Some(id) = parse_msg_ids(&irt).into_iter().next()
99        && references.last() != Some(&id)
100    {
101        references.push(id);
102    }
103    let lines = if headers.get_first_value("X-Rmut-Partial").is_some() {
104        None // header-only IMAP cache file: the body is elsewhere
105    } else {
106        Some(body_lines(&raw))
107    };
108    let list = headers
109        .get_first_value("List-Id")
110        .and_then(|v| list_name(&v))
111        .map(|n| one_line(&n));
112    let label = headers
113        .get_first_value("X-Label")
114        .map(|v| one_line(&v))
115        .filter(|v| !v.trim().is_empty());
116    let broken = headers
117        .get_first_value(BROKEN_HEADER)
118        .is_some_and(|v| v.trim().eq_ignore_ascii_case(BROKEN_VALUE));
119    Ok(Envelope {
120        file,
121        from,
122        from_full,
123        subject,
124        date,
125        msg_id,
126        references,
127        tagged: false,
128        to,
129        cc,
130        lines,
131        list,
132        label,
133        broken,
134    })
135}
136
137/// Lines in the body part (after the first blank line), counted on the
138/// raw bytes, cheap enough to do for every envelope.
139pub fn body_lines(raw: &[u8]) -> usize {
140    let mut offset = None;
141    let mut i = 0;
142    while i < raw.len() {
143        let Some(end) = raw[i..].iter().position(|&b| b == b'\n').map(|p| i + p) else {
144            break;
145        };
146        let line = &raw[i..end];
147        if line.is_empty() || line == b"\r" {
148            offset = Some(end + 1);
149            break;
150        }
151        i = end + 1;
152    }
153    let Some(offset) = offset else { return 0 };
154    let body = &raw[offset..];
155    body.iter().filter(|&&b| b == b'\n').count()
156        + usize::from(!body.is_empty() && !body.ends_with(b"\n"))
157}
158
159/// A short list name from a List-Id value: the display name when
160/// there is one ("Dev talk <dev.lists.example.com>"), otherwise the
161/// id's first dot-separated label.
162fn list_name(value: &str) -> Option<String> {
163    if let Some((name, _)) = value.split_once('<') {
164        let name = name.trim().trim_matches('"').trim();
165        if !name.is_empty() {
166            return Some(name.to_string());
167        }
168    }
169    let inner = value.trim().trim_start_matches('<').trim_end_matches('>');
170    let label = inner.split('.').next().unwrap_or(inner).trim();
171    (!label.is_empty()).then(|| label.to_string())
172}
173
174/// Bare lowercase addresses in an address header value.
175fn field_addresses(value: &str) -> Vec<String> {
176    let Ok(list) = mailparse::addrparse(value) else {
177        return Vec::new();
178    };
179    let mut out = Vec::new();
180    for addr in list.iter() {
181        match addr {
182            mailparse::MailAddr::Single(single) => out.push(single.addr.to_lowercase()),
183            mailparse::MailAddr::Group(group) => {
184                out.extend(group.addrs.iter().map(|a| a.addr.to_lowercase()));
185            }
186        }
187    }
188    out
189}
190
191/// Extract all `<...>` message-id tokens from a header value.
192pub fn parse_msg_ids(value: &str) -> Vec<String> {
193    let mut ids = Vec::new();
194    let mut rest = value;
195    while let Some(start) = rest.find('<') {
196        let Some(len) = rest[start..].find('>') else {
197            break;
198        };
199        ids.push(rest[start..=start + len].to_string());
200        rest = &rest[start + len + 1..];
201    }
202    ids
203}
204
205/// Display name if present, otherwise the bare address.
206pub fn short_from(from: &str) -> String {
207    if let Ok(list) = mailparse::addrparse(from) {
208        for addr in list.iter() {
209            match addr {
210                mailparse::MailAddr::Single(info) => {
211                    return match &info.display_name {
212                        Some(name) if !name.trim().is_empty() => name.clone(),
213                        _ => info.addr.clone(),
214                    };
215                }
216                mailparse::MailAddr::Group(group) => {
217                    if let Some(info) = group.addrs.first() {
218                        return info
219                            .display_name
220                            .clone()
221                            .unwrap_or_else(|| info.addr.clone());
222                    }
223                }
224            }
225        }
226    }
227    from.trim().to_string()
228}
229
230pub fn format_index_date(epoch: i64) -> String {
231    format_index_date_with(epoch, None)
232}
233
234/// Index date column, with an optional strftime override (mutt's
235/// date_format); "%b %e" when unset, like mutt's default index date.
236pub fn format_index_date_with(epoch: i64, format: Option<&str>) -> String {
237    match Local.timestamp_opt(epoch, 0) {
238        chrono::LocalResult::Single(dt) | chrono::LocalResult::Ambiguous(dt, _) => {
239            dt.format(format.unwrap_or("%b %e")).to_string()
240        }
241        chrono::LocalResult::None => "      ".into(),
242    }
243}
244
245/// Full message ready for the pager: mutt-style brief header block, the
246/// complete header list for the `h` toggle, and the decoded text body.
247#[derive(Debug, Clone)]
248pub struct MessageView {
249    pub brief: Vec<(String, String)>,
250    pub all: Vec<(String, String)>,
251    pub body: String,
252}
253
254/// mutt's ignore/unignore/hdr_order: which headers the pager's brief
255/// view shows, and in what order. Entries are lowercase name
256/// prefixes; `*` matches everything; unignore wins over ignore.
257#[derive(Debug, Clone)]
258pub struct HeaderRules {
259    pub ignore: Vec<String>,
260    pub unignore: Vec<String>,
261    pub order: Vec<String>,
262}
263
264impl Default for HeaderRules {
265    /// The classic view: everything hidden except the usual five, in
266    /// their usual order.
267    fn default() -> HeaderRules {
268        let five = || {
269            ["date", "from", "to", "cc", "subject"]
270                .map(String::from)
271                .to_vec()
272        };
273        HeaderRules {
274            ignore: vec!["*".into()],
275            unignore: five(),
276            order: five(),
277        }
278    }
279}
280
281fn prefix_match(prefixes: &[String], name: &str) -> bool {
282    prefixes.iter().any(|p| p == "*" || name.starts_with(p))
283}
284
285/// The brief header view under `rules`: weeded (ignore minus
286/// unignore), then sorted by hdr_order position; unlisted names
287/// keep message order after the listed ones.
288pub fn weed(all: &[(String, String)], rules: &HeaderRules) -> Vec<(String, String)> {
289    let mut shown: Vec<(String, String)> = all
290        .iter()
291        .filter(|(name, _)| {
292            let name = name.to_lowercase();
293            !prefix_match(&rules.ignore, &name) || prefix_match(&rules.unignore, &name)
294        })
295        .cloned()
296        .collect();
297    shown.sort_by_key(|(name, _)| {
298        let name = name.to_lowercase();
299        rules
300            .order
301            .iter()
302            .position(|p| name.starts_with(p))
303            .unwrap_or(rules.order.len())
304    });
305    shown
306}
307
308/// Everything the pager needs to turn a message into text: mutt's
309/// auto_view filters, the header weeding rules, $reflow_text and
310/// $alternative_order.
311#[derive(Debug, Clone)]
312pub struct Display {
313    /// MIME type → shell command rendering the part (stdin → stdout),
314    /// mutt's auto_view. Types are lowercase.
315    pub filters: std::collections::HashMap<String, String>,
316    pub rules: HeaderRules,
317    /// mutt's $reflow_text: put a `format=flowed` part back into
318    /// paragraphs rather than keeping the sender's line breaks.
319    pub reflow: bool,
320    /// mutt's $alternative_order: MIME types (`text/*` allowed), most
321    /// wanted first, consulted before anything else in a
322    /// multipart/alternative.
323    pub alternative_order: Vec<String>,
324}
325
326impl Default for Display {
327    fn default() -> Display {
328        Display {
329            filters: std::collections::HashMap::new(),
330            rules: HeaderRules::default(),
331            reflow: true,
332            alternative_order: Vec::new(),
333        }
334    }
335}
336
337pub fn load(path: &Path) -> Result<MessageView> {
338    load_with(path, &Display::default())
339}
340
341/// Like `load`, but under an explicit `Display`.
342pub fn load_with(path: &Path, disp: &Display) -> Result<MessageView> {
343    let raw = fs::read(path).with_context(|| format!("reading {}", path.display()))?;
344    let mail = parse_mail(&raw).with_context(|| format!("parsing {}", path.display()))?;
345    let all: Vec<(String, String)> = mail
346        .headers
347        .iter()
348        .map(|h| (h.get_key(), one_line(&h.get_value())))
349        .collect();
350    let brief = weed(&all, &disp.rules);
351    let mut body = String::new();
352    if !render(&mail, disp, &mut body) && body.is_empty() {
353        body = "[-- no displayable text part --]".into();
354    }
355    Ok(MessageView { brief, all, body })
356}
357
358/// Render a MIME entity that is not a file of its own — the plaintext
359/// gpg hands back for a PGP/MIME message — exactly as the pager
360/// renders a message body: the whole tree, attachments announced,
361/// filters applied. Text that is not MIME at all comes back as it is.
362pub fn render_entity(raw: &[u8], disp: &Display) -> String {
363    let Ok(mail) = parse_mail(raw) else {
364        return String::from_utf8_lossy(raw).into_owned();
365    };
366    let mut out = String::new();
367    // Nothing displayable means it was not really a MIME entity (a
368    // sender who armored plain text without headers): show the text.
369    if !render(&mail, disp, &mut out) || out.trim().is_empty() {
370        return String::from_utf8_lossy(raw).into_owned();
371    }
372    out
373}
374
375/// Mutt's pager rendering: the whole MIME tree, depth-first. Text
376/// parts (any subtype, like mutt, including raw html) and filtered
377/// types show inline, multipart/alternative collapses to its best
378/// subpart, message/rfc822 shows its weeded headers then its own
379/// tree, and every subpart of a multipart is announced with mutt's
380/// `[-- Attachment #N --]` / `[-- Type: ... --]` marker block. Parts
381/// that cannot display leave a one-line stub. Returns whether
382/// anything actually displayed (as opposed to only stubs), so the
383/// caller can tell an empty body from an undisplayable message.
384fn render(part: &ParsedMail, disp: &Display, out: &mut String) -> bool {
385    let ty = part.ctype.mimetype.clone();
386    if ty == "multipart/alternative" {
387        return match pick_alternative(&part.subparts, disp) {
388            Some(best) => render(best, disp, out),
389            None => {
390                gap(out);
391                out.push_str("[-- multipart/alternative: no displayable part --]\n");
392                false
393            }
394        };
395    }
396    if ty.starts_with("multipart/") {
397        let mut shown = false;
398        for (i, sub) in part.subparts.iter().enumerate() {
399            marker(sub, i + 1, out);
400            shown |= render(sub, disp, out);
401        }
402        return shown;
403    }
404    if let Some(command) = disp.filters.get(&ty) {
405        gap(out);
406        let text = part
407            .get_body_raw()
408            .map_err(anyhow::Error::from)
409            .and_then(|raw| run_filter(command, &raw));
410        return match text {
411            Ok(text) => {
412                out.push_str(&format!("[-- Autoview using {command} --]\n\n{text}"));
413                ensure_newline(out);
414                true
415            }
416            Err(err) => {
417                out.push_str(&format!("[-- filter {command} failed: {err:#} --]\n"));
418                false
419            }
420        };
421    }
422    if ty == "message/rfc822" {
423        if let Ok(raw) = part.get_body_raw()
424            && let Ok(embedded) = parse_mail(&raw)
425        {
426            gap(out);
427            let all: Vec<(String, String)> = embedded
428                .headers
429                .iter()
430                .map(|h| (h.get_key(), h.get_value()))
431                .collect();
432            for (name, value) in weed(&all, &disp.rules) {
433                out.push_str(&format!("{name}: {value}\n"));
434            }
435            out.push('\n');
436            return render(&embedded, disp, out);
437        }
438        gap(out);
439        out.push_str("[-- message/rfc822: cannot parse --]\n");
440        return false;
441    }
442    if ty.starts_with("text/")
443        && let Ok(text) = part.get_body()
444    {
445        gap(out);
446        // RFC 3676: a flowed part goes back to one line per paragraph
447        // so the pager wraps it at the display width, rather than
448        // keeping whatever width the sender happened to use.
449        match flowed_delsp(part).filter(|_| disp.reflow) {
450            Some(delsp) => out.push_str(&crate::flowed::unflow(&text, delsp)),
451            None => out.push_str(&text),
452        }
453        ensure_newline(out);
454        return true;
455    }
456    gap(out);
457    out.push_str(&format!(
458        "[-- {ty} is unsupported (use 'v' to view this part) --]\n"
459    ));
460    false
461}
462
463/// `Some(delsp)` when the part is `text/plain; format=flowed`, with
464/// the DelSp parameter (default no) alongside.
465fn flowed_delsp(part: &ParsedMail) -> Option<bool> {
466    let value = |name: &str| {
467        part.ctype
468            .params
469            .iter()
470            .find(|(k, _)| k.eq_ignore_ascii_case(name))
471            .map(|(_, v)| v.trim().to_lowercase())
472    };
473    if part.ctype.mimetype != "text/plain" || value("format").as_deref() != Some("flowed") {
474        return None;
475    }
476    Some(value("delsp").as_deref() == Some("yes"))
477}
478
479/// Mutt's alternative_handler order: $alternative_order first, most
480/// wanted type first; then a part with an auto_view filter; then the
481/// richest text part (html < plain < enriched, later parts win ties,
482/// like mutt); then anything displayable at all.
483fn pick_alternative<'a, 'b>(
484    subs: &'a [ParsedMail<'b>],
485    disp: &Display,
486) -> Option<&'a ParsedMail<'b>> {
487    for want in &disp.alternative_order {
488        if let Some(p) = subs
489            .iter()
490            .find(|p| type_matches(want, &p.ctype.mimetype) && displayable(p, &disp.filters))
491        {
492            return Some(p);
493        }
494    }
495    if let Some(p) = subs
496        .iter()
497        .rev()
498        .find(|p| disp.filters.contains_key(&p.ctype.mimetype))
499    {
500        return Some(p);
501    }
502    let rank = |p: &ParsedMail| match p.ctype.mimetype.as_str() {
503        "text/enriched" => 3,
504        "text/plain" => 2,
505        "text/html" => 1,
506        _ => 0,
507    };
508    if let Some((_, p)) = subs
509        .iter()
510        .enumerate()
511        .filter(|(_, p)| rank(p) > 0)
512        .max_by_key(|&(i, p)| (rank(p), i))
513    {
514        return Some(p);
515    }
516    subs.iter().find(|p| displayable(p, &disp.filters))
517}
518
519/// An $alternative_order entry against a part's type: an exact match,
520/// or mutt's `type/*` wildcard (a bare `type` means the same).
521fn type_matches(want: &str, mimetype: &str) -> bool {
522    let want = want.trim().to_lowercase();
523    if want.is_empty() {
524        return false;
525    }
526    match want.strip_suffix("/*").unwrap_or(&want) {
527        main if main == want && want.contains('/') => want == mimetype,
528        main => mimetype.split('/').next() == Some(main),
529    }
530}
531
532/// Can this part (or anything inside it) show in the pager?
533fn displayable(part: &ParsedMail, filters: &std::collections::HashMap<String, String>) -> bool {
534    let ty = &part.ctype.mimetype;
535    ty.starts_with("text/")
536        || ty == "message/rfc822"
537        || filters.contains_key(ty)
538        || (ty.starts_with("multipart/") && part.subparts.iter().any(|s| displayable(s, filters)))
539}
540
541/// Mutt's attachment announcement in the pager:
542/// `[-- Attachment #2: report.pdf --]`
543/// `[-- Type: application/pdf, Encoding: base64, Size: 12K --]`
544fn marker(part: &ParsedMail, count: usize, out: &mut String) {
545    gap(out);
546    let name = part
547        .get_headers()
548        .get_first_value("Content-Description")
549        .or_else(|| part_filename(part));
550    match name {
551        Some(n) => out.push_str(&format!("[-- Attachment #{count}: {n} --]\n")),
552        None => out.push_str(&format!("[-- Attachment #{count} --]\n")),
553    }
554    let encoding = part
555        .get_headers()
556        .get_first_value("Content-Transfer-Encoding")
557        .map(|e| e.to_lowercase())
558        .unwrap_or_else(|| "7bit".into());
559    let size = part.get_body_raw().map(|b| b.len()).unwrap_or(0);
560    out.push_str(&format!(
561        "[-- Type: {}, Encoding: {encoding}, Size: {} --]\n",
562        part.ctype.mimetype,
563        pretty_size(size)
564    ));
565}
566
567/// One blank separator line between rendered pieces, none at the top.
568fn gap(out: &mut String) {
569    if out.is_empty() {
570        return;
571    }
572    while !out.ends_with("\n\n") {
573        out.push('\n');
574    }
575}
576
577fn ensure_newline(out: &mut String) {
578    if !out.ends_with('\n') {
579        out.push('\n');
580    }
581}
582
583/// Mutt's mutt_pretty_size: 0K, 3.2K, 128K, 1.3M, 12M.
584fn pretty_size(n: usize) -> String {
585    if n == 0 {
586        "0K".into()
587    } else if n < 10189 {
588        format!("{:.1}K", n as f64 / 1024.0)
589    } else if n < 1023949 {
590        format!("{}K", (n + 51) / 1024)
591    } else if n < 10433332 {
592        format!("{:.1}M", n as f64 / 1048576.0)
593    } else {
594        format!("{}M", n / 1048576)
595    }
596}
597
598/// Decoded part rendered through a filter command (attachment viewer).
599pub fn filter_part(path: &Path, index: usize, command: &str) -> Result<String> {
600    let raw = fs::read(path).with_context(|| format!("reading {}", path.display()))?;
601    let mail = parse_mail(&raw)?;
602    let bytes = leaf_at(&mail, index)?.get_body_raw()?;
603    run_filter(command, &bytes)
604}
605
606/// sh -c `command` with the part on stdin, capturing stdout. A
607/// mailcap command with `%s` wants the part in a file instead
608/// (RFC 1524), so it gets a temporary one, removed afterwards.
609fn run_filter(command: &str, input: &[u8]) -> Result<String> {
610    match command.contains("%s") {
611        true => {
612            let file = TempPart::new(input)?;
613            let quoted = format!(
614                "'{}'",
615                file.path.display().to_string().replace('\'', r"'\''")
616            );
617            run_piped(&command.replace("%s", &quoted), b"")
618        }
619        false => run_piped(command, input),
620    }
621}
622
623/// A part written out for a `%s` filter, deleted when it drops.
624struct TempPart {
625    path: std::path::PathBuf,
626}
627
628impl TempPart {
629    fn new(input: &[u8]) -> Result<TempPart> {
630        static N: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
631        let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
632        let path = std::env::temp_dir().join(format!("rmut-part-{}-{n}", std::process::id()));
633        fs::write(&path, input).with_context(|| format!("writing {}", path.display()))?;
634        Ok(TempPart { path })
635    }
636}
637
638impl Drop for TempPart {
639    fn drop(&mut self) {
640        let _ = fs::remove_file(&self.path);
641    }
642}
643
644fn run_piped(command: &str, input: &[u8]) -> Result<String> {
645    use std::io::Write as _;
646    let mut child = std::process::Command::new("sh")
647        .arg("-c")
648        .arg(command)
649        .stdin(std::process::Stdio::piped())
650        .stdout(std::process::Stdio::piped())
651        .stderr(std::process::Stdio::null())
652        .spawn()
653        .with_context(|| format!("running {command}"))?;
654    let mut stdin = child.stdin.take().context("no stdin on filter child")?;
655    let input = input.to_vec();
656    let writer = std::thread::spawn(move || {
657        let _ = stdin.write_all(&input);
658    });
659    let out = child.wait_with_output()?;
660    let _ = writer.join();
661    anyhow::ensure!(out.status.success(), "{command} exited with {}", out.status);
662    Ok(String::from_utf8_lossy(&out.stdout).into_owned())
663}
664
665/// The message with one header replaced: every existing line for
666/// `name` (folded continuations included) is dropped, and, when
667/// `value` is Some and non-empty, one `name: value` line is written
668/// at the end of the header block. Used by edit-label; the line
669/// ending of the original is kept.
670pub fn with_header(raw: &[u8], name: &str, value: Option<&str>) -> Vec<u8> {
671    let (head, body) = match raw.windows(4).position(|w| w == b"\r\n\r\n") {
672        Some(at) => (&raw[..at + 2], &raw[at + 2..]),
673        None => match raw.windows(2).position(|w| w == b"\n\n") {
674            Some(at) => (&raw[..at + 1], &raw[at + 1..]),
675            None => (raw, &raw[raw.len()..]),
676        },
677    };
678    let crlf = head.windows(2).any(|w| w == b"\r\n");
679    let eol: &[u8] = if crlf { b"\r\n" } else { b"\n" };
680    let prefix = format!("{}:", name.to_ascii_lowercase());
681    let mut out = Vec::with_capacity(raw.len() + name.len() + 32);
682    let mut skipping = false;
683    for line in head.split_inclusive(|&b| b == b'\n') {
684        let folded = line.first().is_some_and(|b| *b == b' ' || *b == b'\t');
685        if folded && skipping {
686            continue;
687        }
688        let lower: Vec<u8> = line
689            .iter()
690            .take(prefix.len())
691            .map(u8::to_ascii_lowercase)
692            .collect();
693        skipping = lower == prefix.as_bytes();
694        if !skipping {
695            out.extend_from_slice(line);
696        }
697    }
698    if let Some(v) = value.filter(|v| !v.trim().is_empty()) {
699        out.extend_from_slice(name.as_bytes());
700        out.extend_from_slice(b": ");
701        out.extend_from_slice(v.as_bytes());
702        out.extend_from_slice(eol);
703    }
704    out.extend_from_slice(body);
705    out
706}
707
708/// The message with its threading headers replaced: In-Reply-To and
709/// References (folded continuation lines included) are dropped, and
710/// the given ones written at the end of the header block, when there
711/// are any. This is what break-thread and link-threads write back;
712/// mutt does the same to the message itself (`mutt_break_thread`
713/// clears both, `link_threads` sets In-Reply-To to the parent's id).
714/// The line ending of the original is kept.
715pub fn with_thread_headers(
716    raw: &[u8],
717    in_reply_to: Option<&str>,
718    references: &[String],
719    broken: bool,
720) -> Vec<u8> {
721    let (head, body) = match raw.windows(4).position(|w| w == b"\r\n\r\n") {
722        Some(at) => (&raw[..at + 2], &raw[at + 2..]),
723        None => match raw.windows(2).position(|w| w == b"\n\n") {
724            Some(at) => (&raw[..at + 1], &raw[at + 1..]),
725            None => (raw, &raw[raw.len()..]),
726        },
727    };
728    let crlf = head.windows(2).any(|w| w == b"\r\n");
729    let eol: &[u8] = if crlf { b"\r\n" } else { b"\n" };
730    let mut out = Vec::with_capacity(raw.len() + 128);
731    let mut skipping = false;
732    for line in head.split_inclusive(|&b| b == b'\n') {
733        let folded = line.first().is_some_and(|b| *b == b' ' || *b == b'\t');
734        if folded && skipping {
735            continue;
736        }
737        let lower: Vec<u8> = line.iter().take(14).map(u8::to_ascii_lowercase).collect();
738        skipping = lower.starts_with(b"in-reply-to:")
739            || lower.starts_with(b"references:")
740            || lower.starts_with(b"x-rmut-thread:");
741        if !skipping {
742            out.extend_from_slice(line);
743        }
744    }
745    if let Some(id) = in_reply_to {
746        out.extend_from_slice(b"In-Reply-To: ");
747        out.extend_from_slice(id.as_bytes());
748        out.extend_from_slice(eol);
749    }
750    if !references.is_empty() {
751        out.extend_from_slice(b"References: ");
752        out.extend_from_slice(references.join(" ").as_bytes());
753        out.extend_from_slice(eol);
754    }
755    if broken {
756        out.extend_from_slice(format!("{BROKEN_HEADER}: {BROKEN_VALUE}").as_bytes());
757        out.extend_from_slice(eol);
758    }
759    out.extend_from_slice(body);
760    out
761}
762
763/// Decoded value of the first `name` header, read from disk (used by
764/// the `~e` Sender pattern).
765pub fn first_header(path: &Path, name: &str) -> Option<String> {
766    let raw = fs::read(path).ok()?;
767    let mail = parse_mail(&raw).ok()?;
768    mail.get_headers().get_first_value(name)
769}
770
771/// The whole decoded header block as `Name: value` lines, for the
772/// `~h` pattern (mutt matches the header text, not one field).
773pub fn header_text(path: &Path) -> Option<String> {
774    let raw = fs::read(path).ok()?;
775    let mail = parse_mail(&raw).ok()?;
776    let mut out = String::new();
777    for header in mail.get_headers() {
778        out.push_str(&header.get_key());
779        out.push_str(": ");
780        out.push_str(&header.get_value());
781        out.push('\n');
782    }
783    Some(out)
784}
785
786/// Decoded text body only (used by `~b` pattern matching).
787pub fn body_text(path: &Path) -> Result<String> {
788    let raw = fs::read(path).with_context(|| format!("reading {}", path.display()))?;
789    let mail = parse_mail(&raw).with_context(|| format!("parsing {}", path.display()))?;
790    Ok(extract_text(&mail).unwrap_or_default())
791}
792
793/// Depth-first search for the first text/plain part (falling back to any
794/// text/* part), with transfer encoding and charset decoded by mailparse.
795pub(crate) fn extract_text(mail: &ParsedMail) -> Option<String> {
796    if mail.subparts.is_empty() {
797        if mail.ctype.mimetype.starts_with("text/") {
798            return mail.get_body().ok();
799        }
800        return None;
801    }
802    for sub in &mail.subparts {
803        if sub.ctype.mimetype == "text/plain"
804            && sub.subparts.is_empty()
805            && let Ok(body) = sub.get_body()
806        {
807            return Some(body);
808        }
809    }
810    for sub in &mail.subparts {
811        if let Some(body) = extract_text(sub) {
812            return Some(body);
813        }
814    }
815    None
816}
817
818/// One leaf MIME part, for the attachment menu.
819#[derive(Debug, Clone)]
820pub struct Part {
821    pub mimetype: String,
822    pub filename: Option<String>,
823    /// Decoded size in bytes.
824    pub size: usize,
825    pub is_text: bool,
826}
827
828fn leaves<'a, 'b>(mail: &'a ParsedMail<'b>, out: &mut Vec<&'a ParsedMail<'b>>) {
829    if mail.subparts.is_empty() {
830        out.push(mail);
831    } else {
832        for sub in &mail.subparts {
833            leaves(sub, out);
834        }
835    }
836}
837
838pub fn parts(path: &Path) -> Result<Vec<Part>> {
839    let raw = fs::read(path).with_context(|| format!("reading {}", path.display()))?;
840    let mail = parse_mail(&raw).with_context(|| format!("parsing {}", path.display()))?;
841    let mut all = Vec::new();
842    leaves(&mail, &mut all);
843    Ok(all
844        .iter()
845        .map(|p| Part {
846            mimetype: p.ctype.mimetype.clone(),
847            filename: part_filename(p),
848            size: p.get_body_raw().map(|b| b.len()).unwrap_or(0),
849            is_text: p.ctype.mimetype.starts_with("text/"),
850        })
851        .collect())
852}
853
854/// The part's file name: Content-Disposition filename, or the
855/// Content-Type name parameter.
856fn part_filename(p: &ParsedMail) -> Option<String> {
857    p.get_content_disposition()
858        .params
859        .get("filename")
860        .cloned()
861        .or_else(|| p.ctype.params.get("name").cloned())
862        .map(|n| one_line(&n))
863}
864
865fn leaf_at<'a, 'b>(mail: &'a ParsedMail<'b>, index: usize) -> Result<&'a ParsedMail<'b>> {
866    let mut all = Vec::new();
867    leaves(mail, &mut all);
868    all.get(index).copied().context("no such part")
869}
870
871/// Decoded text of the given leaf part.
872pub fn part_text(path: &Path, index: usize) -> Result<String> {
873    let raw = fs::read(path).with_context(|| format!("reading {}", path.display()))?;
874    let mail = parse_mail(&raw)?;
875    Ok(leaf_at(&mail, index)?.get_body()?)
876}
877
878/// Decoded bytes of the given leaf part (for saving to a file).
879pub fn part_bytes(path: &Path, index: usize) -> Result<Vec<u8>> {
880    let raw = fs::read(path).with_context(|| format!("reading {}", path.display()))?;
881    let mail = parse_mail(&raw)?;
882    Ok(leaf_at(&mail, index)?.get_body_raw()?)
883}
884
885#[cfg(test)]
886mod tests {
887    use super::*;
888
889    #[test]
890    fn weed_applies_ignore_unignore_and_order() {
891        let all: Vec<(String, String)> = [
892            ("Received", "relay"),
893            ("Subject", "hi"),
894            ("X-Topic", "budget"),
895            ("From", "jane@example.com"),
896            ("X-Spam-Score", "0"),
897        ]
898        .map(|(a, b)| (a.to_string(), b.to_string()))
899        .to_vec();
900        let names = |v: &[(String, String)]| v.iter().map(|(n, _)| n.clone()).collect::<Vec<_>>();
901        // The classic default: only the usual five, in their order.
902        assert_eq!(
903            names(&weed(&all, &HeaderRules::default())),
904            ["From", "Subject"]
905        );
906        // Prefix ignore with an unignore exception; no order keeps
907        // message order.
908        let rules = HeaderRules {
909            ignore: vec!["x-".into(), "received".into()],
910            unignore: vec!["x-topic".into()],
911            order: vec![],
912        };
913        assert_eq!(names(&weed(&all, &rules)), ["Subject", "X-Topic", "From"]);
914        // hdr_order sorts the listed prefixes first, the rest after.
915        let rules = HeaderRules {
916            ignore: vec!["*".into()],
917            unignore: vec!["subject".into(), "x-topic".into(), "from".into()],
918            order: vec!["x-topic".into(), "from".into()],
919        };
920        assert_eq!(names(&weed(&all, &rules)), ["X-Topic", "From", "Subject"]);
921    }
922
923    const MULTIPART: &str = concat!(
924        "From: a@example.com\r\n",
925        "Subject: multi\r\n",
926        "MIME-Version: 1.0\r\n",
927        "Content-Type: multipart/mixed; boundary=\"b\"\r\n",
928        "\r\n",
929        "--b\r\n",
930        "Content-Type: text/plain\r\n",
931        "\r\n",
932        "plain text\r\n",
933        "--b\r\n",
934        "Content-Type: application/pdf; name=\"report.pdf\"\r\n",
935        "Content-Disposition: attachment; filename=\"report.pdf\"\r\n",
936        "Content-Transfer-Encoding: base64\r\n",
937        "\r\n",
938        "JVBERg==\r\n",
939        "--b--\r\n",
940    );
941
942    #[test]
943    fn control_characters_never_reach_a_one_line_field() {
944        // A tab is the one that bites: the terminal expands it, so the
945        // rest of an index row is pushed past the window edge and the
946        // row wraps onto a second line.
947        assert_eq!(one_line("before\ttab after"), "before tab after");
948        assert_eq!(one_line("two\u{7}bells\u{1b}"), "two bells ");
949        assert_eq!(one_line("nothing to do"), "nothing to do");
950
951        let raw = concat!(
952            "From: Tabbed\tSender <t@example.com>\r\n",
953            "Subject: before\ttab after\r\n",
954            "Date: Mon, 6 Jul 2026 10:00:00 +0200\r\n",
955            "\r\nbody\r\n",
956        );
957        let tmp = tempfile::tempdir().unwrap();
958        let path = tmp.path().join("cur-msg");
959        std::fs::write(&path, raw).unwrap();
960        let file = crate::maildir::MailFile {
961            path: path.clone(),
962            is_new: false,
963            flags: Default::default(),
964            size: raw.len() as u64,
965        };
966        let env = envelope(file).unwrap();
967        assert_eq!(env.subject, "before tab after");
968        assert_eq!(env.from, "Tabbed Sender");
969        assert!(!env.from_full.contains('\t'));
970        // The pager's header block is a set of one-line slots too.
971        let view = load(&path).unwrap();
972        assert!(
973            view.all.iter().all(|(_, v)| !v.contains('\t')),
974            "{:?}",
975            view.all
976        );
977    }
978
979    #[test]
980    fn short_from_prefers_display_name() {
981        assert_eq!(short_from("Jane Doe <jane@example.com>"), "Jane Doe");
982        assert_eq!(short_from("jane@example.com"), "jane@example.com");
983        assert_eq!(short_from(""), "");
984    }
985
986    #[test]
987    fn extract_text_picks_plain_from_multipart() {
988        let mail = parse_mail(MULTIPART.as_bytes()).unwrap();
989        assert_eq!(extract_text(&mail).unwrap().trim(), "plain text");
990    }
991
992    #[test]
993    fn parts_lists_leaves_with_filenames() {
994        let tmp = tempfile::tempdir().unwrap();
995        let path = tmp.path().join("msg");
996        std::fs::write(&path, MULTIPART).unwrap();
997        let parts = parts(&path).unwrap();
998        assert_eq!(parts.len(), 2);
999        assert!(parts[0].is_text && parts[0].filename.is_none());
1000        assert_eq!(parts[1].mimetype, "application/pdf");
1001        assert_eq!(parts[1].filename.as_deref(), Some("report.pdf"));
1002        // base64 "JVBERg==" decodes to %PDF.
1003        assert_eq!(part_bytes(&path, 1).unwrap(), b"%PDF");
1004        assert_eq!(part_text(&path, 0).unwrap().trim(), "plain text");
1005    }
1006
1007    #[test]
1008    fn parse_msg_ids_handles_lists_and_garbage() {
1009        assert_eq!(parse_msg_ids("<a@x> <b@y>"), vec!["<a@x>", "<b@y>"]);
1010        assert_eq!(parse_msg_ids("junk <a@x> junk"), vec!["<a@x>"]);
1011        assert!(parse_msg_ids("no ids here <broken").is_empty());
1012    }
1013
1014    #[test]
1015    fn envelope_counts_lines_and_finds_the_list() {
1016        let tmp = tempfile::tempdir().unwrap();
1017        let file = |name: &str, content: &str| {
1018            let path = tmp.path().join(name);
1019            std::fs::write(&path, content).unwrap();
1020            envelope(crate::maildir::MailFile {
1021                path,
1022                is_new: false,
1023                flags: Default::default(),
1024                size: 0,
1025            })
1026            .unwrap()
1027        };
1028        let env = file(
1029            "listed",
1030            "From: a@x\r\nList-Id: Dev talk <dev.lists.example.com>\r\nSubject: s\r\n\r\none\r\ntwo\r\nthree",
1031        );
1032        assert_eq!(env.lines, Some(3)); // last line unterminated
1033        assert_eq!(env.list.as_deref(), Some("Dev talk"));
1034        let env = file(
1035            "bare-list",
1036            "From: a@x\r\nList-Id: <announce.example.com>\r\nSubject: s\r\n\r\nhi\r\n",
1037        );
1038        assert_eq!(env.list.as_deref(), Some("announce"));
1039        assert_eq!(env.lines, Some(1));
1040        let env = file("plain", "From: a@x\r\nSubject: s\r\n\r\n");
1041        assert!(env.list.is_none());
1042        assert_eq!(env.lines, Some(0));
1043        // Header-only IMAP cache file: the count is unknown.
1044        let env = file(
1045            "partial",
1046            "X-Rmut-Partial: 1\r\nFrom: a@x\r\nSubject: s\r\n\r\n",
1047        );
1048        assert_eq!(env.lines, None);
1049    }
1050
1051    #[test]
1052    fn envelope_decodes_rfc2047_subject() {
1053        let tmp = tempfile::tempdir().unwrap();
1054        let path = tmp.path().join("msg");
1055        std::fs::write(
1056            &path,
1057            "From: Jane <j@example.com>\r\nSubject: =?utf-8?q?p=C5=99=C3=ADli=C5=A1?=\r\nDate: Mon, 6 Jul 2026 10:00:00 +0200\r\nMessage-ID: <one@x>\r\nReferences: <root@x>\r\nIn-Reply-To: <parent@x>\r\n\r\nhi\r\n",
1058        )
1059        .unwrap();
1060        let env = envelope(crate::maildir::MailFile {
1061            path,
1062            is_new: true,
1063            flags: Default::default(),
1064            size: 0,
1065        })
1066        .unwrap();
1067        assert_eq!(env.subject, "příliš");
1068        assert_eq!(env.from, "Jane");
1069        assert!(env.date > 0);
1070        assert_eq!(env.msg_id.as_deref(), Some("<one@x>"));
1071        assert_eq!(env.references, vec!["<root@x>", "<parent@x>"]);
1072    }
1073
1074    #[test]
1075    fn load_collects_brief_and_all_headers() {
1076        let tmp = tempfile::tempdir().unwrap();
1077        let path = tmp.path().join("msg");
1078        std::fs::write(
1079            &path,
1080            "From: a@x\r\nTo: b@y\r\nSubject: s\r\nX-Custom: z\r\n\r\nbody\r\n",
1081        )
1082        .unwrap();
1083        let view = load(&path).unwrap();
1084        assert_eq!(view.brief.len(), 3); // From, To, Subject (no Date/Cc)
1085        assert_eq!(view.all.len(), 4);
1086        assert!(view.all.iter().any(|(k, _)| k == "X-Custom"));
1087    }
1088
1089    fn body_of(raw: &str) -> String {
1090        let tmp = tempfile::tempdir().unwrap();
1091        let path = tmp.path().join("msg");
1092        std::fs::write(&path, raw).unwrap();
1093        load(&path).unwrap().body
1094    }
1095
1096    #[test]
1097    fn render_shows_text_attachments_with_markers() {
1098        let body = body_of(concat!(
1099            "From: a@example.com\r\n",
1100            "MIME-Version: 1.0\r\n",
1101            "Content-Type: multipart/mixed; boundary=\"b\"\r\n",
1102            "\r\n",
1103            "--b\r\n",
1104            "Content-Type: text/plain\r\n",
1105            "\r\n",
1106            "the body\r\n",
1107            "--b\r\n",
1108            "Content-Type: text/plain; name=\"notes.txt\"\r\n",
1109            "Content-Disposition: attachment; filename=\"notes.txt\"\r\n",
1110            "\r\n",
1111            "attached notes\r\n",
1112            "--b--\r\n",
1113        ));
1114        assert!(body.contains("[-- Attachment #1 --]"), "{body}");
1115        assert!(body.contains("the body"), "{body}");
1116        assert!(body.contains("[-- Attachment #2: notes.txt --]"), "{body}");
1117        assert!(
1118            body.contains("[-- Type: text/plain, Encoding: 7bit, Size: 0.0K --]"),
1119            "{body}"
1120        );
1121        assert!(body.contains("attached notes"), "{body}");
1122    }
1123
1124    #[test]
1125    fn render_stubs_non_text_attachments() {
1126        let body = body_of(MULTIPART);
1127        assert!(body.contains("plain text"), "{body}");
1128        assert!(body.contains("[-- Attachment #2: report.pdf --]"), "{body}");
1129        assert!(
1130            body.contains("[-- Type: application/pdf, Encoding: base64, Size: 0.0K --]"),
1131            "{body}"
1132        );
1133        assert!(
1134            body.contains("[-- application/pdf is unsupported (use 'v' to view this part) --]"),
1135            "{body}"
1136        );
1137    }
1138
1139    const ALTERNATIVE: &str = concat!(
1140        "From: a@example.com\r\n",
1141        "MIME-Version: 1.0\r\n",
1142        "Content-Type: multipart/alternative; boundary=\"b\"\r\n",
1143        "\r\n",
1144        "--b\r\n",
1145        "Content-Type: text/plain\r\n",
1146        "\r\n",
1147        "plain version\r\n",
1148        "--b\r\n",
1149        "Content-Type: text/html\r\n",
1150        "\r\n",
1151        "<b>html version</b>\r\n",
1152        "--b--\r\n",
1153    );
1154
1155    const FLOWED: &str = concat!(
1156        "From: a@example.com\r\n",
1157        "Subject: flowed\r\n",
1158        "MIME-Version: 1.0\r\n",
1159        "Content-Type: text/plain; charset=us-ascii; Format=Flowed\r\n",
1160        "\r\n",
1161        "This paragraph was \r\n",
1162        "split by the sender.\r\n",
1163        "\r\n",
1164        "> quoted and \r\n",
1165        "> continued\r\n",
1166        "-- \r\n",
1167        "Jane\r\n",
1168    );
1169
1170    #[test]
1171    fn flowed_parts_come_back_as_paragraphs() {
1172        // The parameter is matched case-insensitively, like its value.
1173        let body = body_of(FLOWED);
1174        assert!(
1175            body.contains("This paragraph was split by the sender."),
1176            "{body}"
1177        );
1178        assert!(body.contains("> quoted and continued"), "{body}");
1179        // RFC 3676 keeps the signature separator a fixed line.
1180        assert!(body.contains("-- \nJane"), "{body:?}");
1181        // reflow_text = false leaves the sender's line breaks alone.
1182        let tmp = tempfile::tempdir().unwrap();
1183        let path = tmp.path().join("msg");
1184        std::fs::write(&path, FLOWED).unwrap();
1185        let plain = load_with(
1186            &path,
1187            &Display {
1188                reflow: false,
1189                ..Display::default()
1190            },
1191        )
1192        .unwrap()
1193        .body;
1194        // Untouched, CRLF and all, exactly as the part arrived.
1195        assert!(plain.contains("This paragraph was \r\nsplit"), "{plain:?}");
1196    }
1197
1198    #[test]
1199    fn alternative_order_outranks_the_text_ranking() {
1200        let tmp = tempfile::tempdir().unwrap();
1201        let path = tmp.path().join("msg");
1202        std::fs::write(&path, ALTERNATIVE).unwrap();
1203        let order = |types: &[&str]| Display {
1204            alternative_order: types.iter().map(|t| t.to_string()).collect(),
1205            ..Display::default()
1206        };
1207        // html asked for by name beats plain, which the ranking prefers.
1208        let body = load_with(&path, &order(&["text/html"])).unwrap().body;
1209        assert!(body.contains("<b>html version</b>"), "{body}");
1210        assert!(!body.contains("plain version"), "{body}");
1211        // First entry that is actually there wins.
1212        let body = load_with(&path, &order(&["text/enriched", "text/plain"]))
1213            .unwrap()
1214            .body;
1215        assert!(body.contains("plain version"), "{body}");
1216        // A wildcard takes the first part of that main type.
1217        let body = load_with(&path, &order(&["text/*"])).unwrap().body;
1218        assert!(body.contains("plain version"), "{body}");
1219        // Nothing listed matches: back to the ranking.
1220        let body = load_with(&path, &order(&["application/pdf"])).unwrap().body;
1221        assert!(body.contains("plain version"), "{body}");
1222        // An order entry beats an auto_view filter, unlike the ranking.
1223        let body = load_with(
1224            &path,
1225            &Display {
1226                filters: std::collections::HashMap::from([(
1227                    "text/html".to_string(),
1228                    "cat".to_string(),
1229                )]),
1230                alternative_order: vec!["text/plain".into()],
1231                ..Display::default()
1232            },
1233        )
1234        .unwrap()
1235        .body;
1236        assert!(body.contains("plain version"), "{body}");
1237    }
1238
1239    #[test]
1240    fn render_entity_shows_the_whole_tree() {
1241        // What gpg hands back for an encrypted message with an
1242        // attachment: text plus a part that only a marker can show.
1243        let entity = concat!(
1244            "Content-Type: multipart/mixed; boundary=\"m\"\r\n",
1245            "\r\n",
1246            "--m\r\n",
1247            "Content-Type: text/plain\r\n",
1248            "\r\n",
1249            "the secret plan\r\n",
1250            "--m\r\n",
1251            "Content-Type: application/pdf\r\n",
1252            "Content-Disposition: attachment; filename=\"plan.pdf\"\r\n",
1253            "Content-Transfer-Encoding: base64\r\n",
1254            "\r\n",
1255            "cGxhbg==\r\n",
1256            "--m--\r\n",
1257        );
1258        let body = render_entity(entity.as_bytes(), &Display::default());
1259        assert!(body.contains("the secret plan"), "{body}");
1260        assert!(body.contains("[-- Attachment #2: plan.pdf --]"), "{body}");
1261        // Not MIME at all: the text comes back as it stands.
1262        assert_eq!(
1263            render_entity(b"just words", &Display::default()),
1264            "just words"
1265        );
1266    }
1267
1268    #[test]
1269    fn render_alternative_prefers_plain_but_autoview_wins() {
1270        // No filter: mutt's text ranking picks plain over html, no markers.
1271        let body = body_of(ALTERNATIVE);
1272        assert!(body.contains("plain version"), "{body}");
1273        assert!(!body.contains("html version"), "{body}");
1274        assert!(!body.contains("Attachment #"), "{body}");
1275        // An auto_view filter for text/html beats the text ranking.
1276        let tmp = tempfile::tempdir().unwrap();
1277        let path = tmp.path().join("msg");
1278        std::fs::write(&path, ALTERNATIVE).unwrap();
1279        let filters =
1280            std::collections::HashMap::from([("text/html".to_string(), "cat".to_string())]);
1281        let body = load_with(
1282            &path,
1283            &Display {
1284                filters,
1285                ..Display::default()
1286            },
1287        )
1288        .unwrap()
1289        .body;
1290        assert!(body.contains("[-- Autoview using cat --]"), "{body}");
1291        assert!(body.contains("<b>html version</b>"), "{body}");
1292        assert!(!body.contains("plain version"), "{body}");
1293    }
1294
1295    #[test]
1296    fn render_rfc822_shows_embedded_headers_and_body() {
1297        let body = body_of(concat!(
1298            "From: a@example.com\r\n",
1299            "MIME-Version: 1.0\r\n",
1300            "Content-Type: multipart/mixed; boundary=\"b\"\r\n",
1301            "\r\n",
1302            "--b\r\n",
1303            "Content-Type: text/plain\r\n",
1304            "\r\n",
1305            "see below\r\n",
1306            "--b\r\n",
1307            "Content-Type: message/rfc822\r\n",
1308            "\r\n",
1309            "From: jane@example.com\r\n",
1310            "Subject: inner\r\n",
1311            "\r\n",
1312            "inner body\r\n",
1313            "--b--\r\n",
1314        ));
1315        assert!(body.contains("[-- Attachment #2 --]"), "{body}");
1316        assert!(body.contains("[-- Type: message/rfc822"), "{body}");
1317        assert!(body.contains("From: jane@example.com"), "{body}");
1318        assert!(body.contains("Subject: inner"), "{body}");
1319        assert!(body.contains("inner body"), "{body}");
1320    }
1321
1322    #[test]
1323    fn thread_headers_are_replaced_whole() {
1324        let raw = b"From: a@x\r\nReferences: <a@x>\r\n <b@x>\r\nSubject: s\r\nIn-Reply-To: <b@x>\r\n\r\nbody\r\n";
1325        let out = with_thread_headers(raw, None, &[], false);
1326        assert_eq!(out, b"From: a@x\r\nSubject: s\r\n\r\nbody\r\n");
1327        // break-thread's marker goes on, and comes off again when the
1328        // message is linked back under a parent.
1329        let broken = with_thread_headers(raw, None, &[], true);
1330        assert_eq!(
1331            broken,
1332            b"From: a@x\r\nSubject: s\r\nX-Rmut-Thread: broken\r\n\r\nbody\r\n"
1333        );
1334        let out = with_thread_headers(
1335            &broken,
1336            Some("<p@x>"),
1337            &["<r@x>".into(), "<p@x>".into()],
1338            false,
1339        );
1340        assert_eq!(
1341            out,
1342            b"From: a@x\r\nSubject: s\r\nIn-Reply-To: <p@x>\r\nReferences: <r@x> <p@x>\r\n\r\nbody\r\n"
1343        );
1344        // LF mail stays LF, and a message with no body is fine.
1345        let out = with_thread_headers(b"From: a@x\nSubject: s\n", Some("<p@x>"), &[], false);
1346        assert_eq!(out, b"From: a@x\nSubject: s\nIn-Reply-To: <p@x>\n");
1347    }
1348}