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