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}
370
371impl Default for Display {
372    fn default() -> Display {
373        Display {
374            filters: std::collections::HashMap::new(),
375            rules: HeaderRules::default(),
376            reflow: true,
377            alternative_order: Vec::new(),
378        }
379    }
380}
381
382pub fn load(path: &Path) -> Result<MessageView> {
383    load_with(path, &Display::default())
384}
385
386/// Like `load`, but under an explicit `Display`.
387pub fn load_with(path: &Path, disp: &Display) -> Result<MessageView> {
388    let raw = fs::read(path).with_context(|| format!("reading {}", path.display()))?;
389    let mail = parse_mail(&raw).with_context(|| format!("parsing {}", path.display()))?;
390    let all: Vec<(String, String)> = mail
391        .headers
392        .iter()
393        .map(|h| (h.get_key(), one_line(&h.get_value())))
394        .collect();
395    let brief = weed(&all, &disp.rules);
396    let mut body = String::new();
397    if !render(&mail, disp, &mut body) && body.is_empty() {
398        body = "[-- no displayable text part --]".into();
399    }
400    Ok(MessageView { brief, all, body })
401}
402
403/// Render a MIME entity that is not a file of its own — the plaintext
404/// gpg hands back for a PGP/MIME message — exactly as the pager
405/// renders a message body: the whole tree, attachments announced,
406/// filters applied. Text that is not MIME at all comes back as it is.
407pub fn render_entity(raw: &[u8], disp: &Display) -> String {
408    let Ok(mail) = parse_mail(raw) else {
409        return String::from_utf8_lossy(raw).into_owned();
410    };
411    let mut out = String::new();
412    // Nothing displayable means it was not really a MIME entity (a
413    // sender who armored plain text without headers): show the text.
414    if !render(&mail, disp, &mut out) || out.trim().is_empty() {
415        return String::from_utf8_lossy(raw).into_owned();
416    }
417    out
418}
419
420/// Mutt's pager rendering: the whole MIME tree, depth-first. Text
421/// parts (any subtype, like mutt, including raw html) and filtered
422/// types show inline, multipart/alternative collapses to its best
423/// subpart, message/rfc822 shows its weeded headers then its own
424/// tree, and every subpart of a multipart is announced with mutt's
425/// `[-- Attachment #N --]` / `[-- Type: ... --]` marker block. Parts
426/// that cannot display leave a one-line stub. Returns whether
427/// anything actually displayed (as opposed to only stubs), so the
428/// caller can tell an empty body from an undisplayable message.
429fn render(part: &ParsedMail, disp: &Display, out: &mut String) -> bool {
430    let ty = part.ctype.mimetype.clone();
431    if ty == "multipart/alternative" {
432        return match pick_alternative(&part.subparts, disp) {
433            Some(best) => render(best, disp, out),
434            None => {
435                gap(out);
436                out.push_str("[-- multipart/alternative: no displayable part --]\n");
437                false
438            }
439        };
440    }
441    if ty.starts_with("multipart/") {
442        let mut shown = false;
443        for (i, sub) in part.subparts.iter().enumerate() {
444            marker(sub, i + 1, out);
445            shown |= render(sub, disp, out);
446        }
447        return shown;
448    }
449    if let Some(command) = disp.filters.get(&ty) {
450        gap(out);
451        let text = part
452            .get_body_raw()
453            .map_err(anyhow::Error::from)
454            .and_then(|raw| run_filter(command, &raw));
455        return match text {
456            Ok(text) => {
457                out.push_str(&format!("[-- Autoview using {command} --]\n\n{text}"));
458                ensure_newline(out);
459                true
460            }
461            Err(err) => {
462                out.push_str(&format!("[-- filter {command} failed: {err:#} --]\n"));
463                false
464            }
465        };
466    }
467    if ty == "message/rfc822" {
468        if let Ok(raw) = part.get_body_raw()
469            && let Ok(embedded) = parse_mail(&raw)
470        {
471            gap(out);
472            let all: Vec<(String, String)> = embedded
473                .headers
474                .iter()
475                .map(|h| (h.get_key(), h.get_value()))
476                .collect();
477            for (name, value) in weed(&all, &disp.rules) {
478                out.push_str(&format!("{name}: {value}\n"));
479            }
480            out.push('\n');
481            return render(&embedded, disp, out);
482        }
483        gap(out);
484        out.push_str("[-- message/rfc822: cannot parse --]\n");
485        return false;
486    }
487    if ty.starts_with("text/")
488        && let Ok(text) = part.get_body()
489    {
490        gap(out);
491        // RFC 3676: a flowed part goes back to one line per paragraph
492        // so the pager wraps it at the display width, rather than
493        // keeping whatever width the sender happened to use.
494        match flowed_delsp(part).filter(|_| disp.reflow) {
495            Some(delsp) => out.push_str(&crate::flowed::unflow(&text, delsp)),
496            None => out.push_str(&text),
497        }
498        ensure_newline(out);
499        return true;
500    }
501    gap(out);
502    out.push_str(&format!(
503        "[-- {ty} is unsupported (use 'v' to view this part) --]\n"
504    ));
505    false
506}
507
508/// `Some(delsp)` when the part is `text/plain; format=flowed`, with
509/// the DelSp parameter (default no) alongside.
510fn flowed_delsp(part: &ParsedMail) -> Option<bool> {
511    let value = |name: &str| {
512        part.ctype
513            .params
514            .iter()
515            .find(|(k, _)| k.eq_ignore_ascii_case(name))
516            .map(|(_, v)| v.trim().to_lowercase())
517    };
518    if part.ctype.mimetype != "text/plain" || value("format").as_deref() != Some("flowed") {
519        return None;
520    }
521    Some(value("delsp").as_deref() == Some("yes"))
522}
523
524/// Mutt's alternative_handler order: $alternative_order first, most
525/// wanted type first; then a part with an auto_view filter; then the
526/// richest text part (html < plain < enriched, later parts win ties,
527/// like mutt); then anything displayable at all.
528fn pick_alternative<'a, 'b>(
529    subs: &'a [ParsedMail<'b>],
530    disp: &Display,
531) -> Option<&'a ParsedMail<'b>> {
532    for want in &disp.alternative_order {
533        if let Some(p) = subs
534            .iter()
535            .find(|p| type_matches(want, &p.ctype.mimetype) && displayable(p, &disp.filters))
536        {
537            return Some(p);
538        }
539    }
540    if let Some(p) = subs
541        .iter()
542        .rev()
543        .find(|p| disp.filters.contains_key(&p.ctype.mimetype))
544    {
545        return Some(p);
546    }
547    let rank = |p: &ParsedMail| match p.ctype.mimetype.as_str() {
548        "text/enriched" => 3,
549        "text/plain" => 2,
550        "text/html" => 1,
551        _ => 0,
552    };
553    if let Some((_, p)) = subs
554        .iter()
555        .enumerate()
556        .filter(|(_, p)| rank(p) > 0)
557        .max_by_key(|&(i, p)| (rank(p), i))
558    {
559        return Some(p);
560    }
561    subs.iter().find(|p| displayable(p, &disp.filters))
562}
563
564/// An $alternative_order entry against a part's type: an exact match,
565/// or mutt's `type/*` wildcard (a bare `type` means the same).
566fn type_matches(want: &str, mimetype: &str) -> bool {
567    let want = want.trim().to_lowercase();
568    if want.is_empty() {
569        return false;
570    }
571    match want.strip_suffix("/*").unwrap_or(&want) {
572        main if main == want && want.contains('/') => want == mimetype,
573        main => mimetype.split('/').next() == Some(main),
574    }
575}
576
577/// Can this part (or anything inside it) show in the pager?
578fn displayable(part: &ParsedMail, filters: &std::collections::HashMap<String, String>) -> bool {
579    let ty = &part.ctype.mimetype;
580    ty.starts_with("text/")
581        || ty == "message/rfc822"
582        || filters.contains_key(ty)
583        || (ty.starts_with("multipart/") && part.subparts.iter().any(|s| displayable(s, filters)))
584}
585
586/// Mutt's attachment announcement in the pager:
587/// `[-- Attachment #2: report.pdf --]`
588/// `[-- Type: application/pdf, Encoding: base64, Size: 12K --]`
589fn marker(part: &ParsedMail, count: usize, out: &mut String) {
590    gap(out);
591    let name = part
592        .get_headers()
593        .get_first_value("Content-Description")
594        .or_else(|| part_filename(part));
595    match name {
596        Some(n) => out.push_str(&format!("[-- Attachment #{count}: {n} --]\n")),
597        None => out.push_str(&format!("[-- Attachment #{count} --]\n")),
598    }
599    let encoding = part
600        .get_headers()
601        .get_first_value("Content-Transfer-Encoding")
602        .map(|e| e.to_lowercase())
603        .unwrap_or_else(|| "7bit".into());
604    let size = part.get_body_raw().map(|b| b.len()).unwrap_or(0);
605    out.push_str(&format!(
606        "[-- Type: {}, Encoding: {encoding}, Size: {} --]\n",
607        part.ctype.mimetype,
608        pretty_size(size)
609    ));
610}
611
612/// One blank separator line between rendered pieces, none at the top.
613fn gap(out: &mut String) {
614    if out.is_empty() {
615        return;
616    }
617    while !out.ends_with("\n\n") {
618        out.push('\n');
619    }
620}
621
622fn ensure_newline(out: &mut String) {
623    if !out.ends_with('\n') {
624        out.push('\n');
625    }
626}
627
628/// Mutt's mutt_pretty_size: 0K, 3.2K, 128K, 1.3M, 12M.
629fn pretty_size(n: usize) -> String {
630    if n == 0 {
631        "0K".into()
632    } else if n < 10189 {
633        format!("{:.1}K", n as f64 / 1024.0)
634    } else if n < 1023949 {
635        format!("{}K", (n + 51) / 1024)
636    } else if n < 10433332 {
637        format!("{:.1}M", n as f64 / 1048576.0)
638    } else {
639        format!("{}M", n / 1048576)
640    }
641}
642
643/// Decoded part rendered through a filter command (attachment viewer).
644pub fn filter_part(path: &Path, index: usize, command: &str) -> Result<String> {
645    let raw = fs::read(path).with_context(|| format!("reading {}", path.display()))?;
646    let mail = parse_mail(&raw)?;
647    let bytes = leaf_at(&mail, index)?.get_body_raw()?;
648    run_filter(command, &bytes)
649}
650
651/// sh -c `command` with the part on stdin, capturing stdout. A
652/// mailcap command with `%s` wants the part in a file instead
653/// (RFC 1524), so it gets a temporary one, removed afterwards.
654fn run_filter(command: &str, input: &[u8]) -> Result<String> {
655    match command.contains("%s") {
656        true => {
657            let file = TempPart::new(input)?;
658            let quoted = format!(
659                "'{}'",
660                file.path.display().to_string().replace('\'', r"'\''")
661            );
662            run_piped(&command.replace("%s", &quoted), b"")
663        }
664        false => run_piped(command, input),
665    }
666}
667
668/// A part written out for a `%s` filter, deleted when it drops.
669struct TempPart {
670    path: std::path::PathBuf,
671}
672
673impl TempPart {
674    fn new(input: &[u8]) -> Result<TempPart> {
675        static N: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
676        let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
677        let path = std::env::temp_dir().join(format!("rmut-part-{}-{n}", std::process::id()));
678        fs::write(&path, input).with_context(|| format!("writing {}", path.display()))?;
679        Ok(TempPart { path })
680    }
681}
682
683impl Drop for TempPart {
684    fn drop(&mut self) {
685        let _ = fs::remove_file(&self.path);
686    }
687}
688
689fn run_piped(command: &str, input: &[u8]) -> Result<String> {
690    use std::io::Write as _;
691    let mut child = std::process::Command::new("sh")
692        .arg("-c")
693        .arg(command)
694        .stdin(std::process::Stdio::piped())
695        .stdout(std::process::Stdio::piped())
696        .stderr(std::process::Stdio::null())
697        .spawn()
698        .with_context(|| format!("running {command}"))?;
699    let mut stdin = child.stdin.take().context("no stdin on filter child")?;
700    let input = input.to_vec();
701    let writer = std::thread::spawn(move || {
702        let _ = stdin.write_all(&input);
703    });
704    let out = child.wait_with_output()?;
705    let _ = writer.join();
706    anyhow::ensure!(out.status.success(), "{command} exited with {}", out.status);
707    Ok(String::from_utf8_lossy(&out.stdout).into_owned())
708}
709
710/// The message with one header replaced: every existing line for
711/// `name` (folded continuations included) is dropped, and, when
712/// `value` is Some and non-empty, one `name: value` line is written
713/// at the end of the header block. Used by edit-label; the line
714/// ending of the original is kept.
715pub fn with_header(raw: &[u8], name: &str, value: Option<&str>) -> Vec<u8> {
716    let (head, body) = match raw.windows(4).position(|w| w == b"\r\n\r\n") {
717        Some(at) => (&raw[..at + 2], &raw[at + 2..]),
718        None => match raw.windows(2).position(|w| w == b"\n\n") {
719            Some(at) => (&raw[..at + 1], &raw[at + 1..]),
720            None => (raw, &raw[raw.len()..]),
721        },
722    };
723    let crlf = head.windows(2).any(|w| w == b"\r\n");
724    let eol: &[u8] = if crlf { b"\r\n" } else { b"\n" };
725    let prefix = format!("{}:", name.to_ascii_lowercase());
726    let mut out = Vec::with_capacity(raw.len() + name.len() + 32);
727    let mut skipping = false;
728    for line in head.split_inclusive(|&b| b == b'\n') {
729        let folded = line.first().is_some_and(|b| *b == b' ' || *b == b'\t');
730        if folded && skipping {
731            continue;
732        }
733        let lower: Vec<u8> = line
734            .iter()
735            .take(prefix.len())
736            .map(u8::to_ascii_lowercase)
737            .collect();
738        skipping = lower == prefix.as_bytes();
739        if !skipping {
740            out.extend_from_slice(line);
741        }
742    }
743    if let Some(v) = value.filter(|v| !v.trim().is_empty()) {
744        out.extend_from_slice(name.as_bytes());
745        out.extend_from_slice(b": ");
746        out.extend_from_slice(v.as_bytes());
747        out.extend_from_slice(eol);
748    }
749    out.extend_from_slice(body);
750    out
751}
752
753/// The message with its threading headers replaced: In-Reply-To and
754/// References (folded continuation lines included) are dropped, and
755/// the given ones written at the end of the header block, when there
756/// are any. This is what break-thread and link-threads write back;
757/// mutt does the same to the message itself (`mutt_break_thread`
758/// clears both, `link_threads` sets In-Reply-To to the parent's id).
759/// The line ending of the original is kept.
760pub fn with_thread_headers(
761    raw: &[u8],
762    in_reply_to: Option<&str>,
763    references: &[String],
764    broken: bool,
765) -> Vec<u8> {
766    let (head, body) = match raw.windows(4).position(|w| w == b"\r\n\r\n") {
767        Some(at) => (&raw[..at + 2], &raw[at + 2..]),
768        None => match raw.windows(2).position(|w| w == b"\n\n") {
769            Some(at) => (&raw[..at + 1], &raw[at + 1..]),
770            None => (raw, &raw[raw.len()..]),
771        },
772    };
773    let crlf = head.windows(2).any(|w| w == b"\r\n");
774    let eol: &[u8] = if crlf { b"\r\n" } else { b"\n" };
775    let mut out = Vec::with_capacity(raw.len() + 128);
776    let mut skipping = false;
777    for line in head.split_inclusive(|&b| b == b'\n') {
778        let folded = line.first().is_some_and(|b| *b == b' ' || *b == b'\t');
779        if folded && skipping {
780            continue;
781        }
782        let lower: Vec<u8> = line.iter().take(14).map(u8::to_ascii_lowercase).collect();
783        skipping = lower.starts_with(b"in-reply-to:")
784            || lower.starts_with(b"references:")
785            || lower.starts_with(b"x-rmut-thread:");
786        if !skipping {
787            out.extend_from_slice(line);
788        }
789    }
790    if let Some(id) = in_reply_to {
791        out.extend_from_slice(b"In-Reply-To: ");
792        out.extend_from_slice(id.as_bytes());
793        out.extend_from_slice(eol);
794    }
795    if !references.is_empty() {
796        out.extend_from_slice(b"References: ");
797        out.extend_from_slice(references.join(" ").as_bytes());
798        out.extend_from_slice(eol);
799    }
800    if broken {
801        out.extend_from_slice(format!("{BROKEN_HEADER}: {BROKEN_VALUE}").as_bytes());
802        out.extend_from_slice(eol);
803    }
804    out.extend_from_slice(body);
805    out
806}
807
808/// Decoded value of the first `name` header, read from disk (used by
809/// the `~e` Sender pattern).
810pub fn first_header(path: &Path, name: &str) -> Option<String> {
811    let raw = fs::read(path).ok()?;
812    let mail = parse_mail(&raw).ok()?;
813    mail.get_headers().get_first_value(name)
814}
815
816/// The whole decoded header block as `Name: value` lines, for the
817/// `~h` pattern (mutt matches the header text, not one field).
818pub fn header_text(path: &Path) -> Option<String> {
819    let raw = fs::read(path).ok()?;
820    let mail = parse_mail(&raw).ok()?;
821    let mut out = String::new();
822    for header in mail.get_headers() {
823        out.push_str(&header.get_key());
824        out.push_str(": ");
825        out.push_str(&header.get_value());
826        out.push('\n');
827    }
828    Some(out)
829}
830
831/// Decoded text body only (used by `~b` pattern matching).
832pub fn body_text(path: &Path) -> Result<String> {
833    let raw = fs::read(path).with_context(|| format!("reading {}", path.display()))?;
834    let mail = parse_mail(&raw).with_context(|| format!("parsing {}", path.display()))?;
835    Ok(extract_text(&mail).unwrap_or_default())
836}
837
838/// Depth-first search for the first text/plain part (falling back to any
839/// text/* part), with transfer encoding and charset decoded by mailparse.
840pub(crate) fn extract_text(mail: &ParsedMail) -> Option<String> {
841    if mail.subparts.is_empty() {
842        if mail.ctype.mimetype.starts_with("text/") {
843            return mail.get_body().ok();
844        }
845        return None;
846    }
847    for sub in &mail.subparts {
848        if sub.ctype.mimetype == "text/plain"
849            && sub.subparts.is_empty()
850            && let Ok(body) = sub.get_body()
851        {
852            return Some(body);
853        }
854    }
855    for sub in &mail.subparts {
856        if let Some(body) = extract_text(sub) {
857            return Some(body);
858        }
859    }
860    None
861}
862
863/// One leaf MIME part, for the attachment menu.
864#[derive(Debug, Clone)]
865pub struct Part {
866    pub mimetype: String,
867    pub filename: Option<String>,
868    /// Decoded size in bytes.
869    pub size: usize,
870    pub is_text: bool,
871}
872
873fn leaves<'a, 'b>(mail: &'a ParsedMail<'b>, out: &mut Vec<&'a ParsedMail<'b>>) {
874    if mail.subparts.is_empty() {
875        out.push(mail);
876    } else {
877        for sub in &mail.subparts {
878            leaves(sub, out);
879        }
880    }
881}
882
883pub fn parts(path: &Path) -> Result<Vec<Part>> {
884    let raw = fs::read(path).with_context(|| format!("reading {}", path.display()))?;
885    let mail = parse_mail(&raw).with_context(|| format!("parsing {}", path.display()))?;
886    let mut all = Vec::new();
887    leaves(&mail, &mut all);
888    Ok(all
889        .iter()
890        .map(|p| Part {
891            mimetype: p.ctype.mimetype.clone(),
892            filename: part_filename(p),
893            size: p.get_body_raw().map(|b| b.len()).unwrap_or(0),
894            is_text: p.ctype.mimetype.starts_with("text/"),
895        })
896        .collect())
897}
898
899/// The part's file name: Content-Disposition filename, or the
900/// Content-Type name parameter.
901fn part_filename(p: &ParsedMail) -> Option<String> {
902    p.get_content_disposition()
903        .params
904        .get("filename")
905        .cloned()
906        .or_else(|| p.ctype.params.get("name").cloned())
907        .map(|n| one_line(&n))
908}
909
910fn leaf_at<'a, 'b>(mail: &'a ParsedMail<'b>, index: usize) -> Result<&'a ParsedMail<'b>> {
911    let mut all = Vec::new();
912    leaves(mail, &mut all);
913    all.get(index).copied().context("no such part")
914}
915
916/// Decoded text of the given leaf part.
917pub fn part_text(path: &Path, index: usize) -> Result<String> {
918    let raw = fs::read(path).with_context(|| format!("reading {}", path.display()))?;
919    let mail = parse_mail(&raw)?;
920    Ok(leaf_at(&mail, index)?.get_body()?)
921}
922
923/// Decoded bytes of the given leaf part (for saving to a file).
924pub fn part_bytes(path: &Path, index: usize) -> Result<Vec<u8>> {
925    let raw = fs::read(path).with_context(|| format!("reading {}", path.display()))?;
926    let mail = parse_mail(&raw)?;
927    Ok(leaf_at(&mail, index)?.get_body_raw()?)
928}
929
930#[cfg(test)]
931mod tests {
932    use super::*;
933
934    #[test]
935    fn weed_applies_ignore_unignore_and_order() {
936        let all: Vec<(String, String)> = [
937            ("Received", "relay"),
938            ("Subject", "hi"),
939            ("X-Topic", "budget"),
940            ("From", "jane@example.com"),
941            ("X-Spam-Score", "0"),
942        ]
943        .map(|(a, b)| (a.to_string(), b.to_string()))
944        .to_vec();
945        let names = |v: &[(String, String)]| v.iter().map(|(n, _)| n.clone()).collect::<Vec<_>>();
946        // The classic default: only the usual five, in their order.
947        assert_eq!(
948            names(&weed(&all, &HeaderRules::default())),
949            ["From", "Subject"]
950        );
951        // Prefix ignore with an unignore exception; no order keeps
952        // message order.
953        let rules = HeaderRules {
954            ignore: vec!["x-".into(), "received".into()],
955            unignore: vec!["x-topic".into()],
956            order: vec![],
957        };
958        assert_eq!(names(&weed(&all, &rules)), ["Subject", "X-Topic", "From"]);
959        // hdr_order sorts the listed prefixes first, the rest after.
960        let rules = HeaderRules {
961            ignore: vec!["*".into()],
962            unignore: vec!["subject".into(), "x-topic".into(), "from".into()],
963            order: vec!["x-topic".into(), "from".into()],
964        };
965        assert_eq!(names(&weed(&all, &rules)), ["X-Topic", "From", "Subject"]);
966    }
967
968    const MULTIPART: &str = concat!(
969        "From: a@example.com\r\n",
970        "Subject: multi\r\n",
971        "MIME-Version: 1.0\r\n",
972        "Content-Type: multipart/mixed; boundary=\"b\"\r\n",
973        "\r\n",
974        "--b\r\n",
975        "Content-Type: text/plain\r\n",
976        "\r\n",
977        "plain text\r\n",
978        "--b\r\n",
979        "Content-Type: application/pdf; name=\"report.pdf\"\r\n",
980        "Content-Disposition: attachment; filename=\"report.pdf\"\r\n",
981        "Content-Transfer-Encoding: base64\r\n",
982        "\r\n",
983        "JVBERg==\r\n",
984        "--b--\r\n",
985    );
986
987    #[test]
988    fn control_characters_never_reach_a_one_line_field() {
989        // A tab is the one that bites: the terminal expands it, so the
990        // rest of an index row is pushed past the window edge and the
991        // row wraps onto a second line.
992        assert_eq!(one_line("before\ttab after"), "before tab after");
993        assert_eq!(one_line("two\u{7}bells\u{1b}"), "two bells ");
994        assert_eq!(one_line("nothing to do"), "nothing to do");
995
996        let raw = concat!(
997            "From: Tabbed\tSender <t@example.com>\r\n",
998            "Subject: before\ttab after\r\n",
999            "Date: Mon, 6 Jul 2026 10:00:00 +0200\r\n",
1000            "\r\nbody\r\n",
1001        );
1002        let tmp = tempfile::tempdir().unwrap();
1003        let path = tmp.path().join("cur-msg");
1004        std::fs::write(&path, raw).unwrap();
1005        let file = crate::maildir::MailFile {
1006            path: path.clone(),
1007            is_new: false,
1008            flags: Default::default(),
1009            size: raw.len() as u64,
1010        };
1011        let env = envelope(file).unwrap();
1012        assert_eq!(env.subject, "before tab after");
1013        assert_eq!(env.from, "Tabbed Sender");
1014        assert!(!env.from_full.contains('\t'));
1015        // The pager's header block is a set of one-line slots too.
1016        let view = load(&path).unwrap();
1017        assert!(
1018            view.all.iter().all(|(_, v)| !v.contains('\t')),
1019            "{:?}",
1020            view.all
1021        );
1022    }
1023
1024    #[test]
1025    fn short_from_prefers_display_name() {
1026        assert_eq!(short_from("Jane Doe <jane@example.com>"), "Jane Doe");
1027        assert_eq!(short_from("jane@example.com"), "jane@example.com");
1028        assert_eq!(short_from(""), "");
1029    }
1030
1031    #[test]
1032    fn extract_text_picks_plain_from_multipart() {
1033        let mail = parse_mail(MULTIPART.as_bytes()).unwrap();
1034        assert_eq!(extract_text(&mail).unwrap().trim(), "plain text");
1035    }
1036
1037    #[test]
1038    fn parts_lists_leaves_with_filenames() {
1039        let tmp = tempfile::tempdir().unwrap();
1040        let path = tmp.path().join("msg");
1041        std::fs::write(&path, MULTIPART).unwrap();
1042        let parts = parts(&path).unwrap();
1043        assert_eq!(parts.len(), 2);
1044        assert!(parts[0].is_text && parts[0].filename.is_none());
1045        assert_eq!(parts[1].mimetype, "application/pdf");
1046        assert_eq!(parts[1].filename.as_deref(), Some("report.pdf"));
1047        // base64 "JVBERg==" decodes to %PDF.
1048        assert_eq!(part_bytes(&path, 1).unwrap(), b"%PDF");
1049        assert_eq!(part_text(&path, 0).unwrap().trim(), "plain text");
1050    }
1051
1052    #[test]
1053    fn parse_msg_ids_handles_lists_and_garbage() {
1054        assert_eq!(parse_msg_ids("<a@x> <b@y>"), vec!["<a@x>", "<b@y>"]);
1055        assert_eq!(parse_msg_ids("junk <a@x> junk"), vec!["<a@x>"]);
1056        assert!(parse_msg_ids("no ids here <broken").is_empty());
1057    }
1058
1059    #[test]
1060    fn envelope_counts_lines_and_finds_the_list() {
1061        let tmp = tempfile::tempdir().unwrap();
1062        let file = |name: &str, content: &str| {
1063            let path = tmp.path().join(name);
1064            std::fs::write(&path, content).unwrap();
1065            envelope(crate::maildir::MailFile {
1066                path,
1067                is_new: false,
1068                flags: Default::default(),
1069                size: 0,
1070            })
1071            .unwrap()
1072        };
1073        let env = file(
1074            "listed",
1075            "From: a@x\r\nList-Id: Dev talk <dev.lists.example.com>\r\nSubject: s\r\n\r\none\r\ntwo\r\nthree",
1076        );
1077        assert_eq!(env.lines, Some(3)); // last line unterminated
1078        assert_eq!(env.list.as_deref(), Some("Dev talk"));
1079        let env = file(
1080            "bare-list",
1081            "From: a@x\r\nList-Id: <announce.example.com>\r\nSubject: s\r\n\r\nhi\r\n",
1082        );
1083        assert_eq!(env.list.as_deref(), Some("announce"));
1084        assert_eq!(env.lines, Some(1));
1085        let env = file("plain", "From: a@x\r\nSubject: s\r\n\r\n");
1086        assert!(env.list.is_none());
1087        assert_eq!(env.lines, Some(0));
1088        // Header-only IMAP cache file: the count is unknown.
1089        let env = file(
1090            "partial",
1091            "X-Rmut-Partial: 1\r\nFrom: a@x\r\nSubject: s\r\n\r\n",
1092        );
1093        assert_eq!(env.lines, None);
1094    }
1095
1096    #[test]
1097    fn envelope_decodes_rfc2047_subject() {
1098        let tmp = tempfile::tempdir().unwrap();
1099        let path = tmp.path().join("msg");
1100        std::fs::write(
1101            &path,
1102            "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",
1103        )
1104        .unwrap();
1105        let env = envelope(crate::maildir::MailFile {
1106            path,
1107            is_new: true,
1108            flags: Default::default(),
1109            size: 0,
1110        })
1111        .unwrap();
1112        assert_eq!(env.subject, "příliš");
1113        assert_eq!(env.from, "Jane");
1114        assert!(env.date > 0);
1115        assert_eq!(env.msg_id.as_deref(), Some("<one@x>"));
1116        assert_eq!(env.references, vec!["<root@x>", "<parent@x>"]);
1117    }
1118
1119    #[test]
1120    fn load_collects_brief_and_all_headers() {
1121        let tmp = tempfile::tempdir().unwrap();
1122        let path = tmp.path().join("msg");
1123        std::fs::write(
1124            &path,
1125            "From: a@x\r\nTo: b@y\r\nSubject: s\r\nX-Custom: z\r\n\r\nbody\r\n",
1126        )
1127        .unwrap();
1128        let view = load(&path).unwrap();
1129        assert_eq!(view.brief.len(), 3); // From, To, Subject (no Date/Cc)
1130        assert_eq!(view.all.len(), 4);
1131        assert!(view.all.iter().any(|(k, _)| k == "X-Custom"));
1132    }
1133
1134    fn body_of(raw: &str) -> String {
1135        let tmp = tempfile::tempdir().unwrap();
1136        let path = tmp.path().join("msg");
1137        std::fs::write(&path, raw).unwrap();
1138        load(&path).unwrap().body
1139    }
1140
1141    #[test]
1142    fn render_shows_text_attachments_with_markers() {
1143        let body = body_of(concat!(
1144            "From: a@example.com\r\n",
1145            "MIME-Version: 1.0\r\n",
1146            "Content-Type: multipart/mixed; boundary=\"b\"\r\n",
1147            "\r\n",
1148            "--b\r\n",
1149            "Content-Type: text/plain\r\n",
1150            "\r\n",
1151            "the body\r\n",
1152            "--b\r\n",
1153            "Content-Type: text/plain; name=\"notes.txt\"\r\n",
1154            "Content-Disposition: attachment; filename=\"notes.txt\"\r\n",
1155            "\r\n",
1156            "attached notes\r\n",
1157            "--b--\r\n",
1158        ));
1159        assert!(body.contains("[-- Attachment #1 --]"), "{body}");
1160        assert!(body.contains("the body"), "{body}");
1161        assert!(body.contains("[-- Attachment #2: notes.txt --]"), "{body}");
1162        assert!(
1163            body.contains("[-- Type: text/plain, Encoding: 7bit, Size: 0.0K --]"),
1164            "{body}"
1165        );
1166        assert!(body.contains("attached notes"), "{body}");
1167    }
1168
1169    #[test]
1170    fn render_stubs_non_text_attachments() {
1171        let body = body_of(MULTIPART);
1172        assert!(body.contains("plain text"), "{body}");
1173        assert!(body.contains("[-- Attachment #2: report.pdf --]"), "{body}");
1174        assert!(
1175            body.contains("[-- Type: application/pdf, Encoding: base64, Size: 0.0K --]"),
1176            "{body}"
1177        );
1178        assert!(
1179            body.contains("[-- application/pdf is unsupported (use 'v' to view this part) --]"),
1180            "{body}"
1181        );
1182    }
1183
1184    const ALTERNATIVE: &str = concat!(
1185        "From: a@example.com\r\n",
1186        "MIME-Version: 1.0\r\n",
1187        "Content-Type: multipart/alternative; boundary=\"b\"\r\n",
1188        "\r\n",
1189        "--b\r\n",
1190        "Content-Type: text/plain\r\n",
1191        "\r\n",
1192        "plain version\r\n",
1193        "--b\r\n",
1194        "Content-Type: text/html\r\n",
1195        "\r\n",
1196        "<b>html version</b>\r\n",
1197        "--b--\r\n",
1198    );
1199
1200    const FLOWED: &str = concat!(
1201        "From: a@example.com\r\n",
1202        "Subject: flowed\r\n",
1203        "MIME-Version: 1.0\r\n",
1204        "Content-Type: text/plain; charset=us-ascii; Format=Flowed\r\n",
1205        "\r\n",
1206        "This paragraph was \r\n",
1207        "split by the sender.\r\n",
1208        "\r\n",
1209        "> quoted and \r\n",
1210        "> continued\r\n",
1211        "-- \r\n",
1212        "Jane\r\n",
1213    );
1214
1215    #[test]
1216    fn flowed_parts_come_back_as_paragraphs() {
1217        // The parameter is matched case-insensitively, like its value.
1218        let body = body_of(FLOWED);
1219        assert!(
1220            body.contains("This paragraph was split by the sender."),
1221            "{body}"
1222        );
1223        assert!(body.contains("> quoted and continued"), "{body}");
1224        // RFC 3676 keeps the signature separator a fixed line.
1225        assert!(body.contains("-- \nJane"), "{body:?}");
1226        // reflow_text = false leaves the sender's line breaks alone.
1227        let tmp = tempfile::tempdir().unwrap();
1228        let path = tmp.path().join("msg");
1229        std::fs::write(&path, FLOWED).unwrap();
1230        let plain = load_with(
1231            &path,
1232            &Display {
1233                reflow: false,
1234                ..Display::default()
1235            },
1236        )
1237        .unwrap()
1238        .body;
1239        // Untouched, CRLF and all, exactly as the part arrived.
1240        assert!(plain.contains("This paragraph was \r\nsplit"), "{plain:?}");
1241    }
1242
1243    #[test]
1244    fn alternative_order_outranks_the_text_ranking() {
1245        let tmp = tempfile::tempdir().unwrap();
1246        let path = tmp.path().join("msg");
1247        std::fs::write(&path, ALTERNATIVE).unwrap();
1248        let order = |types: &[&str]| Display {
1249            alternative_order: types.iter().map(|t| t.to_string()).collect(),
1250            ..Display::default()
1251        };
1252        // html asked for by name beats plain, which the ranking prefers.
1253        let body = load_with(&path, &order(&["text/html"])).unwrap().body;
1254        assert!(body.contains("<b>html version</b>"), "{body}");
1255        assert!(!body.contains("plain version"), "{body}");
1256        // First entry that is actually there wins.
1257        let body = load_with(&path, &order(&["text/enriched", "text/plain"]))
1258            .unwrap()
1259            .body;
1260        assert!(body.contains("plain version"), "{body}");
1261        // A wildcard takes the first part of that main type.
1262        let body = load_with(&path, &order(&["text/*"])).unwrap().body;
1263        assert!(body.contains("plain version"), "{body}");
1264        // Nothing listed matches: back to the ranking.
1265        let body = load_with(&path, &order(&["application/pdf"])).unwrap().body;
1266        assert!(body.contains("plain version"), "{body}");
1267        // An order entry beats an auto_view filter, unlike the ranking.
1268        let body = load_with(
1269            &path,
1270            &Display {
1271                filters: std::collections::HashMap::from([(
1272                    "text/html".to_string(),
1273                    "cat".to_string(),
1274                )]),
1275                alternative_order: vec!["text/plain".into()],
1276                ..Display::default()
1277            },
1278        )
1279        .unwrap()
1280        .body;
1281        assert!(body.contains("plain version"), "{body}");
1282    }
1283
1284    #[test]
1285    fn render_entity_shows_the_whole_tree() {
1286        // What gpg hands back for an encrypted message with an
1287        // attachment: text plus a part that only a marker can show.
1288        let entity = concat!(
1289            "Content-Type: multipart/mixed; boundary=\"m\"\r\n",
1290            "\r\n",
1291            "--m\r\n",
1292            "Content-Type: text/plain\r\n",
1293            "\r\n",
1294            "the secret plan\r\n",
1295            "--m\r\n",
1296            "Content-Type: application/pdf\r\n",
1297            "Content-Disposition: attachment; filename=\"plan.pdf\"\r\n",
1298            "Content-Transfer-Encoding: base64\r\n",
1299            "\r\n",
1300            "cGxhbg==\r\n",
1301            "--m--\r\n",
1302        );
1303        let body = render_entity(entity.as_bytes(), &Display::default());
1304        assert!(body.contains("the secret plan"), "{body}");
1305        assert!(body.contains("[-- Attachment #2: plan.pdf --]"), "{body}");
1306        // Not MIME at all: the text comes back as it stands.
1307        assert_eq!(
1308            render_entity(b"just words", &Display::default()),
1309            "just words"
1310        );
1311    }
1312
1313    #[test]
1314    fn render_alternative_prefers_plain_but_autoview_wins() {
1315        // No filter: mutt's text ranking picks plain over html, no markers.
1316        let body = body_of(ALTERNATIVE);
1317        assert!(body.contains("plain version"), "{body}");
1318        assert!(!body.contains("html version"), "{body}");
1319        assert!(!body.contains("Attachment #"), "{body}");
1320        // An auto_view filter for text/html beats the text ranking.
1321        let tmp = tempfile::tempdir().unwrap();
1322        let path = tmp.path().join("msg");
1323        std::fs::write(&path, ALTERNATIVE).unwrap();
1324        let filters =
1325            std::collections::HashMap::from([("text/html".to_string(), "cat".to_string())]);
1326        let body = load_with(
1327            &path,
1328            &Display {
1329                filters,
1330                ..Display::default()
1331            },
1332        )
1333        .unwrap()
1334        .body;
1335        assert!(body.contains("[-- Autoview using cat --]"), "{body}");
1336        assert!(body.contains("<b>html version</b>"), "{body}");
1337        assert!(!body.contains("plain version"), "{body}");
1338    }
1339
1340    #[test]
1341    fn render_rfc822_shows_embedded_headers_and_body() {
1342        let body = body_of(concat!(
1343            "From: a@example.com\r\n",
1344            "MIME-Version: 1.0\r\n",
1345            "Content-Type: multipart/mixed; boundary=\"b\"\r\n",
1346            "\r\n",
1347            "--b\r\n",
1348            "Content-Type: text/plain\r\n",
1349            "\r\n",
1350            "see below\r\n",
1351            "--b\r\n",
1352            "Content-Type: message/rfc822\r\n",
1353            "\r\n",
1354            "From: jane@example.com\r\n",
1355            "Subject: inner\r\n",
1356            "\r\n",
1357            "inner body\r\n",
1358            "--b--\r\n",
1359        ));
1360        assert!(body.contains("[-- Attachment #2 --]"), "{body}");
1361        assert!(body.contains("[-- Type: message/rfc822"), "{body}");
1362        assert!(body.contains("From: jane@example.com"), "{body}");
1363        assert!(body.contains("Subject: inner"), "{body}");
1364        assert!(body.contains("inner body"), "{body}");
1365    }
1366
1367    #[test]
1368    fn thread_headers_are_replaced_whole() {
1369        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";
1370        let out = with_thread_headers(raw, None, &[], false);
1371        assert_eq!(out, b"From: a@x\r\nSubject: s\r\n\r\nbody\r\n");
1372        // break-thread's marker goes on, and comes off again when the
1373        // message is linked back under a parent.
1374        let broken = with_thread_headers(raw, None, &[], true);
1375        assert_eq!(
1376            broken,
1377            b"From: a@x\r\nSubject: s\r\nX-Rmut-Thread: broken\r\n\r\nbody\r\n"
1378        );
1379        let out = with_thread_headers(
1380            &broken,
1381            Some("<p@x>"),
1382            &["<r@x>".into(), "<p@x>".into()],
1383            false,
1384        );
1385        assert_eq!(
1386            out,
1387            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"
1388        );
1389        // LF mail stays LF, and a message with no body is fine.
1390        let out = with_thread_headers(b"From: a@x\nSubject: s\n", Some("<p@x>"), &[], false);
1391        assert_eq!(out, b"From: a@x\nSubject: s\nIn-Reply-To: <p@x>\n");
1392    }
1393
1394    #[test]
1395    fn list_actions_take_the_first_mailto_of_each_header() {
1396        let raw = b"From: a@x\r\nList-Id: <dev.example.com>\r\n\
1397List-Unsubscribe: <https://lists.example.com/leave>, <mailto:dev-leave@example.com?subject=x>\r\n\
1398List-Help: <https://lists.example.com/help>\r\nSubject: s\r\n\r\nbody\r\n";
1399        let actions = list_actions(raw);
1400        assert_eq!(actions.len(), 6);
1401        assert_eq!(
1402            actions[3],
1403            (
1404                "Unsubscribe",
1405                Some("mailto:dev-leave@example.com?subject=x".to_string())
1406            ),
1407            "the mailto wins over the https that came first"
1408        );
1409        assert_eq!(
1410            actions[0],
1411            ("Help", Some("https://lists.example.com/help".to_string())),
1412            "no mailto: the first URL, so the refusal can name it"
1413        );
1414        assert_eq!(actions[1], ("Post", None));
1415    }
1416}