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