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