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#[derive(Debug, Clone)]
12pub struct Envelope {
13 pub file: MailFile,
14 pub from: String,
16 pub from_full: String,
19 pub subject: String,
20 pub date: i64,
22 pub msg_id: Option<String>,
24 pub references: Vec<String>,
26 pub tagged: bool,
28 pub to: Vec<String>,
31 pub cc: Vec<String>,
32 pub lines: Option<usize>,
36 pub list: Option<String>,
38 pub label: Option<String>,
40 pub broken: bool,
46}
47
48pub const BROKEN_HEADER: &str = "X-Rmut-Thread";
51const BROKEN_VALUE: &str = "broken";
52
53pub fn one_line(text: &str) -> String {
61 text.chars()
62 .map(|c| match c.is_control() {
63 true => ' ',
64 false => c,
65 })
66 .collect()
67}
68
69pub fn envelope(file: MailFile) -> Result<Envelope> {
70 let raw = fs::read(&file.path).with_context(|| format!("reading {}", file.path.display()))?;
71 let mail = parse_mail(&raw).with_context(|| format!("parsing {}", file.path.display()))?;
72 let headers = mail.get_headers();
73 let from_full = one_line(&headers.get_first_value("From").unwrap_or_default());
74 let from = if from_full.trim().is_empty() {
75 "(unknown)".into()
76 } else {
77 short_from(&from_full)
78 };
79 let subject = headers
80 .get_first_value("Subject")
81 .map(|s| one_line(&s))
82 .filter(|s| !s.trim().is_empty())
83 .unwrap_or_else(|| "(no subject)".into());
84 let date = headers
85 .get_first_value("Date")
86 .and_then(|d| mailparse::dateparse(&d).ok())
87 .unwrap_or(0);
88 let msg_id = headers
89 .get_first_value("Message-ID")
90 .and_then(|v| parse_msg_ids(&v).into_iter().next());
91 let to = field_addresses(&headers.get_all_values("To").join(", "));
92 let cc = field_addresses(&headers.get_all_values("Cc").join(", "));
93 let mut references = headers
94 .get_first_value("References")
95 .map(|v| parse_msg_ids(&v))
96 .unwrap_or_default();
97 if let Some(irt) = headers.get_first_value("In-Reply-To")
98 && let Some(id) = parse_msg_ids(&irt).into_iter().next()
99 && references.last() != Some(&id)
100 {
101 references.push(id);
102 }
103 let lines = if headers.get_first_value("X-Rmut-Partial").is_some() {
104 None } else {
106 Some(body_lines(&raw))
107 };
108 let list = headers
109 .get_first_value("List-Id")
110 .and_then(|v| list_name(&v))
111 .map(|n| one_line(&n));
112 let label = headers
113 .get_first_value("X-Label")
114 .map(|v| one_line(&v))
115 .filter(|v| !v.trim().is_empty());
116 let broken = headers
117 .get_first_value(BROKEN_HEADER)
118 .is_some_and(|v| v.trim().eq_ignore_ascii_case(BROKEN_VALUE));
119 Ok(Envelope {
120 file,
121 from,
122 from_full,
123 subject,
124 date,
125 msg_id,
126 references,
127 tagged: false,
128 to,
129 cc,
130 lines,
131 list,
132 label,
133 broken,
134 })
135}
136
137pub fn body_lines(raw: &[u8]) -> usize {
140 let mut offset = None;
141 let mut i = 0;
142 while i < raw.len() {
143 let Some(end) = raw[i..].iter().position(|&b| b == b'\n').map(|p| i + p) else {
144 break;
145 };
146 let line = &raw[i..end];
147 if line.is_empty() || line == b"\r" {
148 offset = Some(end + 1);
149 break;
150 }
151 i = end + 1;
152 }
153 let Some(offset) = offset else { return 0 };
154 let body = &raw[offset..];
155 body.iter().filter(|&&b| b == b'\n').count()
156 + usize::from(!body.is_empty() && !body.ends_with(b"\n"))
157}
158
159fn list_name(value: &str) -> Option<String> {
163 if let Some((name, _)) = value.split_once('<') {
164 let name = name.trim().trim_matches('"').trim();
165 if !name.is_empty() {
166 return Some(name.to_string());
167 }
168 }
169 let inner = value.trim().trim_start_matches('<').trim_end_matches('>');
170 let label = inner.split('.').next().unwrap_or(inner).trim();
171 (!label.is_empty()).then(|| label.to_string())
172}
173
174fn field_addresses(value: &str) -> Vec<String> {
176 let Ok(list) = mailparse::addrparse(value) else {
177 return Vec::new();
178 };
179 let mut out = Vec::new();
180 for addr in list.iter() {
181 match addr {
182 mailparse::MailAddr::Single(single) => out.push(single.addr.to_lowercase()),
183 mailparse::MailAddr::Group(group) => {
184 out.extend(group.addrs.iter().map(|a| a.addr.to_lowercase()));
185 }
186 }
187 }
188 out
189}
190
191pub fn parse_msg_ids(value: &str) -> Vec<String> {
193 let mut ids = Vec::new();
194 let mut rest = value;
195 while let Some(start) = rest.find('<') {
196 let Some(len) = rest[start..].find('>') else {
197 break;
198 };
199 ids.push(rest[start..=start + len].to_string());
200 rest = &rest[start + len + 1..];
201 }
202 ids
203}
204
205pub fn short_from(from: &str) -> String {
207 if let Ok(list) = mailparse::addrparse(from) {
208 for addr in list.iter() {
209 match addr {
210 mailparse::MailAddr::Single(info) => {
211 return match &info.display_name {
212 Some(name) if !name.trim().is_empty() => name.clone(),
213 _ => info.addr.clone(),
214 };
215 }
216 mailparse::MailAddr::Group(group) => {
217 if let Some(info) = group.addrs.first() {
218 return info
219 .display_name
220 .clone()
221 .unwrap_or_else(|| info.addr.clone());
222 }
223 }
224 }
225 }
226 }
227 from.trim().to_string()
228}
229
230pub fn format_index_date(epoch: i64) -> String {
231 format_index_date_with(epoch, None)
232}
233
234pub fn format_index_date_with(epoch: i64, format: Option<&str>) -> String {
237 match Local.timestamp_opt(epoch, 0) {
238 chrono::LocalResult::Single(dt) | chrono::LocalResult::Ambiguous(dt, _) => {
239 dt.format(format.unwrap_or("%b %e")).to_string()
240 }
241 chrono::LocalResult::None => " ".into(),
242 }
243}
244
245#[derive(Debug, Clone)]
248pub struct MessageView {
249 pub brief: Vec<(String, String)>,
250 pub all: Vec<(String, String)>,
251 pub body: String,
252}
253
254#[derive(Debug, Clone)]
258pub struct HeaderRules {
259 pub ignore: Vec<String>,
260 pub unignore: Vec<String>,
261 pub order: Vec<String>,
262}
263
264impl Default for HeaderRules {
265 fn default() -> HeaderRules {
268 let five = || {
269 ["date", "from", "to", "cc", "subject"]
270 .map(String::from)
271 .to_vec()
272 };
273 HeaderRules {
274 ignore: vec!["*".into()],
275 unignore: five(),
276 order: five(),
277 }
278 }
279}
280
281fn prefix_match(prefixes: &[String], name: &str) -> bool {
282 prefixes.iter().any(|p| p == "*" || name.starts_with(p))
283}
284
285pub fn weed(all: &[(String, String)], rules: &HeaderRules) -> Vec<(String, String)> {
289 let mut shown: Vec<(String, String)> = all
290 .iter()
291 .filter(|(name, _)| {
292 let name = name.to_lowercase();
293 !prefix_match(&rules.ignore, &name) || prefix_match(&rules.unignore, &name)
294 })
295 .cloned()
296 .collect();
297 shown.sort_by_key(|(name, _)| {
298 let name = name.to_lowercase();
299 rules
300 .order
301 .iter()
302 .position(|p| name.starts_with(p))
303 .unwrap_or(rules.order.len())
304 });
305 shown
306}
307
308#[derive(Debug, Clone)]
312pub struct Display {
313 pub filters: std::collections::HashMap<String, String>,
316 pub rules: HeaderRules,
317 pub reflow: bool,
320 pub alternative_order: Vec<String>,
324}
325
326impl Default for Display {
327 fn default() -> Display {
328 Display {
329 filters: std::collections::HashMap::new(),
330 rules: HeaderRules::default(),
331 reflow: true,
332 alternative_order: Vec::new(),
333 }
334 }
335}
336
337pub fn load(path: &Path) -> Result<MessageView> {
338 load_with(path, &Display::default())
339}
340
341pub fn load_with(path: &Path, disp: &Display) -> Result<MessageView> {
343 let raw = fs::read(path).with_context(|| format!("reading {}", path.display()))?;
344 let mail = parse_mail(&raw).with_context(|| format!("parsing {}", path.display()))?;
345 let all: Vec<(String, String)> = mail
346 .headers
347 .iter()
348 .map(|h| (h.get_key(), one_line(&h.get_value())))
349 .collect();
350 let brief = weed(&all, &disp.rules);
351 let mut body = String::new();
352 if !render(&mail, disp, &mut body) && body.is_empty() {
353 body = "[-- no displayable text part --]".into();
354 }
355 Ok(MessageView { brief, all, body })
356}
357
358pub fn render_entity(raw: &[u8], disp: &Display) -> String {
363 let Ok(mail) = parse_mail(raw) else {
364 return String::from_utf8_lossy(raw).into_owned();
365 };
366 let mut out = String::new();
367 if !render(&mail, disp, &mut out) || out.trim().is_empty() {
370 return String::from_utf8_lossy(raw).into_owned();
371 }
372 out
373}
374
375fn render(part: &ParsedMail, disp: &Display, out: &mut String) -> bool {
385 let ty = part.ctype.mimetype.clone();
386 if ty == "multipart/alternative" {
387 return match pick_alternative(&part.subparts, disp) {
388 Some(best) => render(best, disp, out),
389 None => {
390 gap(out);
391 out.push_str("[-- multipart/alternative: no displayable part --]\n");
392 false
393 }
394 };
395 }
396 if ty.starts_with("multipart/") {
397 let mut shown = false;
398 for (i, sub) in part.subparts.iter().enumerate() {
399 marker(sub, i + 1, out);
400 shown |= render(sub, disp, out);
401 }
402 return shown;
403 }
404 if let Some(command) = disp.filters.get(&ty) {
405 gap(out);
406 let text = part
407 .get_body_raw()
408 .map_err(anyhow::Error::from)
409 .and_then(|raw| run_filter(command, &raw));
410 return match text {
411 Ok(text) => {
412 out.push_str(&format!("[-- Autoview using {command} --]\n\n{text}"));
413 ensure_newline(out);
414 true
415 }
416 Err(err) => {
417 out.push_str(&format!("[-- filter {command} failed: {err:#} --]\n"));
418 false
419 }
420 };
421 }
422 if ty == "message/rfc822" {
423 if let Ok(raw) = part.get_body_raw()
424 && let Ok(embedded) = parse_mail(&raw)
425 {
426 gap(out);
427 let all: Vec<(String, String)> = embedded
428 .headers
429 .iter()
430 .map(|h| (h.get_key(), h.get_value()))
431 .collect();
432 for (name, value) in weed(&all, &disp.rules) {
433 out.push_str(&format!("{name}: {value}\n"));
434 }
435 out.push('\n');
436 return render(&embedded, disp, out);
437 }
438 gap(out);
439 out.push_str("[-- message/rfc822: cannot parse --]\n");
440 return false;
441 }
442 if ty.starts_with("text/")
443 && let Ok(text) = part.get_body()
444 {
445 gap(out);
446 match flowed_delsp(part).filter(|_| disp.reflow) {
450 Some(delsp) => out.push_str(&crate::flowed::unflow(&text, delsp)),
451 None => out.push_str(&text),
452 }
453 ensure_newline(out);
454 return true;
455 }
456 gap(out);
457 out.push_str(&format!(
458 "[-- {ty} is unsupported (use 'v' to view this part) --]\n"
459 ));
460 false
461}
462
463fn flowed_delsp(part: &ParsedMail) -> Option<bool> {
466 let value = |name: &str| {
467 part.ctype
468 .params
469 .iter()
470 .find(|(k, _)| k.eq_ignore_ascii_case(name))
471 .map(|(_, v)| v.trim().to_lowercase())
472 };
473 if part.ctype.mimetype != "text/plain" || value("format").as_deref() != Some("flowed") {
474 return None;
475 }
476 Some(value("delsp").as_deref() == Some("yes"))
477}
478
479fn pick_alternative<'a, 'b>(
484 subs: &'a [ParsedMail<'b>],
485 disp: &Display,
486) -> Option<&'a ParsedMail<'b>> {
487 for want in &disp.alternative_order {
488 if let Some(p) = subs
489 .iter()
490 .find(|p| type_matches(want, &p.ctype.mimetype) && displayable(p, &disp.filters))
491 {
492 return Some(p);
493 }
494 }
495 if let Some(p) = subs
496 .iter()
497 .rev()
498 .find(|p| disp.filters.contains_key(&p.ctype.mimetype))
499 {
500 return Some(p);
501 }
502 let rank = |p: &ParsedMail| match p.ctype.mimetype.as_str() {
503 "text/enriched" => 3,
504 "text/plain" => 2,
505 "text/html" => 1,
506 _ => 0,
507 };
508 if let Some((_, p)) = subs
509 .iter()
510 .enumerate()
511 .filter(|(_, p)| rank(p) > 0)
512 .max_by_key(|&(i, p)| (rank(p), i))
513 {
514 return Some(p);
515 }
516 subs.iter().find(|p| displayable(p, &disp.filters))
517}
518
519fn type_matches(want: &str, mimetype: &str) -> bool {
522 let want = want.trim().to_lowercase();
523 if want.is_empty() {
524 return false;
525 }
526 match want.strip_suffix("/*").unwrap_or(&want) {
527 main if main == want && want.contains('/') => want == mimetype,
528 main => mimetype.split('/').next() == Some(main),
529 }
530}
531
532fn displayable(part: &ParsedMail, filters: &std::collections::HashMap<String, String>) -> bool {
534 let ty = &part.ctype.mimetype;
535 ty.starts_with("text/")
536 || ty == "message/rfc822"
537 || filters.contains_key(ty)
538 || (ty.starts_with("multipart/") && part.subparts.iter().any(|s| displayable(s, filters)))
539}
540
541fn marker(part: &ParsedMail, count: usize, out: &mut String) {
545 gap(out);
546 let name = part
547 .get_headers()
548 .get_first_value("Content-Description")
549 .or_else(|| part_filename(part));
550 match name {
551 Some(n) => out.push_str(&format!("[-- Attachment #{count}: {n} --]\n")),
552 None => out.push_str(&format!("[-- Attachment #{count} --]\n")),
553 }
554 let encoding = part
555 .get_headers()
556 .get_first_value("Content-Transfer-Encoding")
557 .map(|e| e.to_lowercase())
558 .unwrap_or_else(|| "7bit".into());
559 let size = part.get_body_raw().map(|b| b.len()).unwrap_or(0);
560 out.push_str(&format!(
561 "[-- Type: {}, Encoding: {encoding}, Size: {} --]\n",
562 part.ctype.mimetype,
563 pretty_size(size)
564 ));
565}
566
567fn gap(out: &mut String) {
569 if out.is_empty() {
570 return;
571 }
572 while !out.ends_with("\n\n") {
573 out.push('\n');
574 }
575}
576
577fn ensure_newline(out: &mut String) {
578 if !out.ends_with('\n') {
579 out.push('\n');
580 }
581}
582
583fn pretty_size(n: usize) -> String {
585 if n == 0 {
586 "0K".into()
587 } else if n < 10189 {
588 format!("{:.1}K", n as f64 / 1024.0)
589 } else if n < 1023949 {
590 format!("{}K", (n + 51) / 1024)
591 } else if n < 10433332 {
592 format!("{:.1}M", n as f64 / 1048576.0)
593 } else {
594 format!("{}M", n / 1048576)
595 }
596}
597
598pub fn filter_part(path: &Path, index: usize, command: &str) -> Result<String> {
600 let raw = fs::read(path).with_context(|| format!("reading {}", path.display()))?;
601 let mail = parse_mail(&raw)?;
602 let bytes = leaf_at(&mail, index)?.get_body_raw()?;
603 run_filter(command, &bytes)
604}
605
606fn run_filter(command: &str, input: &[u8]) -> Result<String> {
610 match command.contains("%s") {
611 true => {
612 let file = TempPart::new(input)?;
613 let quoted = format!(
614 "'{}'",
615 file.path.display().to_string().replace('\'', r"'\''")
616 );
617 run_piped(&command.replace("%s", "ed), b"")
618 }
619 false => run_piped(command, input),
620 }
621}
622
623struct TempPart {
625 path: std::path::PathBuf,
626}
627
628impl TempPart {
629 fn new(input: &[u8]) -> Result<TempPart> {
630 static N: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
631 let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
632 let path = std::env::temp_dir().join(format!("rmut-part-{}-{n}", std::process::id()));
633 fs::write(&path, input).with_context(|| format!("writing {}", path.display()))?;
634 Ok(TempPart { path })
635 }
636}
637
638impl Drop for TempPart {
639 fn drop(&mut self) {
640 let _ = fs::remove_file(&self.path);
641 }
642}
643
644fn run_piped(command: &str, input: &[u8]) -> Result<String> {
645 use std::io::Write as _;
646 let mut child = std::process::Command::new("sh")
647 .arg("-c")
648 .arg(command)
649 .stdin(std::process::Stdio::piped())
650 .stdout(std::process::Stdio::piped())
651 .stderr(std::process::Stdio::null())
652 .spawn()
653 .with_context(|| format!("running {command}"))?;
654 let mut stdin = child.stdin.take().context("no stdin on filter child")?;
655 let input = input.to_vec();
656 let writer = std::thread::spawn(move || {
657 let _ = stdin.write_all(&input);
658 });
659 let out = child.wait_with_output()?;
660 let _ = writer.join();
661 anyhow::ensure!(out.status.success(), "{command} exited with {}", out.status);
662 Ok(String::from_utf8_lossy(&out.stdout).into_owned())
663}
664
665pub fn with_header(raw: &[u8], name: &str, value: Option<&str>) -> Vec<u8> {
671 let (head, body) = match raw.windows(4).position(|w| w == b"\r\n\r\n") {
672 Some(at) => (&raw[..at + 2], &raw[at + 2..]),
673 None => match raw.windows(2).position(|w| w == b"\n\n") {
674 Some(at) => (&raw[..at + 1], &raw[at + 1..]),
675 None => (raw, &raw[raw.len()..]),
676 },
677 };
678 let crlf = head.windows(2).any(|w| w == b"\r\n");
679 let eol: &[u8] = if crlf { b"\r\n" } else { b"\n" };
680 let prefix = format!("{}:", name.to_ascii_lowercase());
681 let mut out = Vec::with_capacity(raw.len() + name.len() + 32);
682 let mut skipping = false;
683 for line in head.split_inclusive(|&b| b == b'\n') {
684 let folded = line.first().is_some_and(|b| *b == b' ' || *b == b'\t');
685 if folded && skipping {
686 continue;
687 }
688 let lower: Vec<u8> = line
689 .iter()
690 .take(prefix.len())
691 .map(u8::to_ascii_lowercase)
692 .collect();
693 skipping = lower == prefix.as_bytes();
694 if !skipping {
695 out.extend_from_slice(line);
696 }
697 }
698 if let Some(v) = value.filter(|v| !v.trim().is_empty()) {
699 out.extend_from_slice(name.as_bytes());
700 out.extend_from_slice(b": ");
701 out.extend_from_slice(v.as_bytes());
702 out.extend_from_slice(eol);
703 }
704 out.extend_from_slice(body);
705 out
706}
707
708pub fn with_thread_headers(
716 raw: &[u8],
717 in_reply_to: Option<&str>,
718 references: &[String],
719 broken: bool,
720) -> Vec<u8> {
721 let (head, body) = match raw.windows(4).position(|w| w == b"\r\n\r\n") {
722 Some(at) => (&raw[..at + 2], &raw[at + 2..]),
723 None => match raw.windows(2).position(|w| w == b"\n\n") {
724 Some(at) => (&raw[..at + 1], &raw[at + 1..]),
725 None => (raw, &raw[raw.len()..]),
726 },
727 };
728 let crlf = head.windows(2).any(|w| w == b"\r\n");
729 let eol: &[u8] = if crlf { b"\r\n" } else { b"\n" };
730 let mut out = Vec::with_capacity(raw.len() + 128);
731 let mut skipping = false;
732 for line in head.split_inclusive(|&b| b == b'\n') {
733 let folded = line.first().is_some_and(|b| *b == b' ' || *b == b'\t');
734 if folded && skipping {
735 continue;
736 }
737 let lower: Vec<u8> = line.iter().take(14).map(u8::to_ascii_lowercase).collect();
738 skipping = lower.starts_with(b"in-reply-to:")
739 || lower.starts_with(b"references:")
740 || lower.starts_with(b"x-rmut-thread:");
741 if !skipping {
742 out.extend_from_slice(line);
743 }
744 }
745 if let Some(id) = in_reply_to {
746 out.extend_from_slice(b"In-Reply-To: ");
747 out.extend_from_slice(id.as_bytes());
748 out.extend_from_slice(eol);
749 }
750 if !references.is_empty() {
751 out.extend_from_slice(b"References: ");
752 out.extend_from_slice(references.join(" ").as_bytes());
753 out.extend_from_slice(eol);
754 }
755 if broken {
756 out.extend_from_slice(format!("{BROKEN_HEADER}: {BROKEN_VALUE}").as_bytes());
757 out.extend_from_slice(eol);
758 }
759 out.extend_from_slice(body);
760 out
761}
762
763pub fn first_header(path: &Path, name: &str) -> Option<String> {
766 let raw = fs::read(path).ok()?;
767 let mail = parse_mail(&raw).ok()?;
768 mail.get_headers().get_first_value(name)
769}
770
771pub fn header_text(path: &Path) -> Option<String> {
774 let raw = fs::read(path).ok()?;
775 let mail = parse_mail(&raw).ok()?;
776 let mut out = String::new();
777 for header in mail.get_headers() {
778 out.push_str(&header.get_key());
779 out.push_str(": ");
780 out.push_str(&header.get_value());
781 out.push('\n');
782 }
783 Some(out)
784}
785
786pub fn body_text(path: &Path) -> Result<String> {
788 let raw = fs::read(path).with_context(|| format!("reading {}", path.display()))?;
789 let mail = parse_mail(&raw).with_context(|| format!("parsing {}", path.display()))?;
790 Ok(extract_text(&mail).unwrap_or_default())
791}
792
793pub(crate) fn extract_text(mail: &ParsedMail) -> Option<String> {
796 if mail.subparts.is_empty() {
797 if mail.ctype.mimetype.starts_with("text/") {
798 return mail.get_body().ok();
799 }
800 return None;
801 }
802 for sub in &mail.subparts {
803 if sub.ctype.mimetype == "text/plain"
804 && sub.subparts.is_empty()
805 && let Ok(body) = sub.get_body()
806 {
807 return Some(body);
808 }
809 }
810 for sub in &mail.subparts {
811 if let Some(body) = extract_text(sub) {
812 return Some(body);
813 }
814 }
815 None
816}
817
818#[derive(Debug, Clone)]
820pub struct Part {
821 pub mimetype: String,
822 pub filename: Option<String>,
823 pub size: usize,
825 pub is_text: bool,
826}
827
828fn leaves<'a, 'b>(mail: &'a ParsedMail<'b>, out: &mut Vec<&'a ParsedMail<'b>>) {
829 if mail.subparts.is_empty() {
830 out.push(mail);
831 } else {
832 for sub in &mail.subparts {
833 leaves(sub, out);
834 }
835 }
836}
837
838pub fn parts(path: &Path) -> Result<Vec<Part>> {
839 let raw = fs::read(path).with_context(|| format!("reading {}", path.display()))?;
840 let mail = parse_mail(&raw).with_context(|| format!("parsing {}", path.display()))?;
841 let mut all = Vec::new();
842 leaves(&mail, &mut all);
843 Ok(all
844 .iter()
845 .map(|p| Part {
846 mimetype: p.ctype.mimetype.clone(),
847 filename: part_filename(p),
848 size: p.get_body_raw().map(|b| b.len()).unwrap_or(0),
849 is_text: p.ctype.mimetype.starts_with("text/"),
850 })
851 .collect())
852}
853
854fn part_filename(p: &ParsedMail) -> Option<String> {
857 p.get_content_disposition()
858 .params
859 .get("filename")
860 .cloned()
861 .or_else(|| p.ctype.params.get("name").cloned())
862 .map(|n| one_line(&n))
863}
864
865fn leaf_at<'a, 'b>(mail: &'a ParsedMail<'b>, index: usize) -> Result<&'a ParsedMail<'b>> {
866 let mut all = Vec::new();
867 leaves(mail, &mut all);
868 all.get(index).copied().context("no such part")
869}
870
871pub fn part_text(path: &Path, index: usize) -> Result<String> {
873 let raw = fs::read(path).with_context(|| format!("reading {}", path.display()))?;
874 let mail = parse_mail(&raw)?;
875 Ok(leaf_at(&mail, index)?.get_body()?)
876}
877
878pub fn part_bytes(path: &Path, index: usize) -> Result<Vec<u8>> {
880 let raw = fs::read(path).with_context(|| format!("reading {}", path.display()))?;
881 let mail = parse_mail(&raw)?;
882 Ok(leaf_at(&mail, index)?.get_body_raw()?)
883}
884
885#[cfg(test)]
886mod tests {
887 use super::*;
888
889 #[test]
890 fn weed_applies_ignore_unignore_and_order() {
891 let all: Vec<(String, String)> = [
892 ("Received", "relay"),
893 ("Subject", "hi"),
894 ("X-Topic", "budget"),
895 ("From", "jane@example.com"),
896 ("X-Spam-Score", "0"),
897 ]
898 .map(|(a, b)| (a.to_string(), b.to_string()))
899 .to_vec();
900 let names = |v: &[(String, String)]| v.iter().map(|(n, _)| n.clone()).collect::<Vec<_>>();
901 assert_eq!(
903 names(&weed(&all, &HeaderRules::default())),
904 ["From", "Subject"]
905 );
906 let rules = HeaderRules {
909 ignore: vec!["x-".into(), "received".into()],
910 unignore: vec!["x-topic".into()],
911 order: vec![],
912 };
913 assert_eq!(names(&weed(&all, &rules)), ["Subject", "X-Topic", "From"]);
914 let rules = HeaderRules {
916 ignore: vec!["*".into()],
917 unignore: vec!["subject".into(), "x-topic".into(), "from".into()],
918 order: vec!["x-topic".into(), "from".into()],
919 };
920 assert_eq!(names(&weed(&all, &rules)), ["X-Topic", "From", "Subject"]);
921 }
922
923 const MULTIPART: &str = concat!(
924 "From: a@example.com\r\n",
925 "Subject: multi\r\n",
926 "MIME-Version: 1.0\r\n",
927 "Content-Type: multipart/mixed; boundary=\"b\"\r\n",
928 "\r\n",
929 "--b\r\n",
930 "Content-Type: text/plain\r\n",
931 "\r\n",
932 "plain text\r\n",
933 "--b\r\n",
934 "Content-Type: application/pdf; name=\"report.pdf\"\r\n",
935 "Content-Disposition: attachment; filename=\"report.pdf\"\r\n",
936 "Content-Transfer-Encoding: base64\r\n",
937 "\r\n",
938 "JVBERg==\r\n",
939 "--b--\r\n",
940 );
941
942 #[test]
943 fn control_characters_never_reach_a_one_line_field() {
944 assert_eq!(one_line("before\ttab after"), "before tab after");
948 assert_eq!(one_line("two\u{7}bells\u{1b}"), "two bells ");
949 assert_eq!(one_line("nothing to do"), "nothing to do");
950
951 let raw = concat!(
952 "From: Tabbed\tSender <t@example.com>\r\n",
953 "Subject: before\ttab after\r\n",
954 "Date: Mon, 6 Jul 2026 10:00:00 +0200\r\n",
955 "\r\nbody\r\n",
956 );
957 let tmp = tempfile::tempdir().unwrap();
958 let path = tmp.path().join("cur-msg");
959 std::fs::write(&path, raw).unwrap();
960 let file = crate::maildir::MailFile {
961 path: path.clone(),
962 is_new: false,
963 flags: Default::default(),
964 size: raw.len() as u64,
965 };
966 let env = envelope(file).unwrap();
967 assert_eq!(env.subject, "before tab after");
968 assert_eq!(env.from, "Tabbed Sender");
969 assert!(!env.from_full.contains('\t'));
970 let view = load(&path).unwrap();
972 assert!(
973 view.all.iter().all(|(_, v)| !v.contains('\t')),
974 "{:?}",
975 view.all
976 );
977 }
978
979 #[test]
980 fn short_from_prefers_display_name() {
981 assert_eq!(short_from("Jane Doe <jane@example.com>"), "Jane Doe");
982 assert_eq!(short_from("jane@example.com"), "jane@example.com");
983 assert_eq!(short_from(""), "");
984 }
985
986 #[test]
987 fn extract_text_picks_plain_from_multipart() {
988 let mail = parse_mail(MULTIPART.as_bytes()).unwrap();
989 assert_eq!(extract_text(&mail).unwrap().trim(), "plain text");
990 }
991
992 #[test]
993 fn parts_lists_leaves_with_filenames() {
994 let tmp = tempfile::tempdir().unwrap();
995 let path = tmp.path().join("msg");
996 std::fs::write(&path, MULTIPART).unwrap();
997 let parts = parts(&path).unwrap();
998 assert_eq!(parts.len(), 2);
999 assert!(parts[0].is_text && parts[0].filename.is_none());
1000 assert_eq!(parts[1].mimetype, "application/pdf");
1001 assert_eq!(parts[1].filename.as_deref(), Some("report.pdf"));
1002 assert_eq!(part_bytes(&path, 1).unwrap(), b"%PDF");
1004 assert_eq!(part_text(&path, 0).unwrap().trim(), "plain text");
1005 }
1006
1007 #[test]
1008 fn parse_msg_ids_handles_lists_and_garbage() {
1009 assert_eq!(parse_msg_ids("<a@x> <b@y>"), vec!["<a@x>", "<b@y>"]);
1010 assert_eq!(parse_msg_ids("junk <a@x> junk"), vec!["<a@x>"]);
1011 assert!(parse_msg_ids("no ids here <broken").is_empty());
1012 }
1013
1014 #[test]
1015 fn envelope_counts_lines_and_finds_the_list() {
1016 let tmp = tempfile::tempdir().unwrap();
1017 let file = |name: &str, content: &str| {
1018 let path = tmp.path().join(name);
1019 std::fs::write(&path, content).unwrap();
1020 envelope(crate::maildir::MailFile {
1021 path,
1022 is_new: false,
1023 flags: Default::default(),
1024 size: 0,
1025 })
1026 .unwrap()
1027 };
1028 let env = file(
1029 "listed",
1030 "From: a@x\r\nList-Id: Dev talk <dev.lists.example.com>\r\nSubject: s\r\n\r\none\r\ntwo\r\nthree",
1031 );
1032 assert_eq!(env.lines, Some(3)); assert_eq!(env.list.as_deref(), Some("Dev talk"));
1034 let env = file(
1035 "bare-list",
1036 "From: a@x\r\nList-Id: <announce.example.com>\r\nSubject: s\r\n\r\nhi\r\n",
1037 );
1038 assert_eq!(env.list.as_deref(), Some("announce"));
1039 assert_eq!(env.lines, Some(1));
1040 let env = file("plain", "From: a@x\r\nSubject: s\r\n\r\n");
1041 assert!(env.list.is_none());
1042 assert_eq!(env.lines, Some(0));
1043 let env = file(
1045 "partial",
1046 "X-Rmut-Partial: 1\r\nFrom: a@x\r\nSubject: s\r\n\r\n",
1047 );
1048 assert_eq!(env.lines, None);
1049 }
1050
1051 #[test]
1052 fn envelope_decodes_rfc2047_subject() {
1053 let tmp = tempfile::tempdir().unwrap();
1054 let path = tmp.path().join("msg");
1055 std::fs::write(
1056 &path,
1057 "From: Jane <j@example.com>\r\nSubject: =?utf-8?q?p=C5=99=C3=ADli=C5=A1?=\r\nDate: Mon, 6 Jul 2026 10:00:00 +0200\r\nMessage-ID: <one@x>\r\nReferences: <root@x>\r\nIn-Reply-To: <parent@x>\r\n\r\nhi\r\n",
1058 )
1059 .unwrap();
1060 let env = envelope(crate::maildir::MailFile {
1061 path,
1062 is_new: true,
1063 flags: Default::default(),
1064 size: 0,
1065 })
1066 .unwrap();
1067 assert_eq!(env.subject, "příliš");
1068 assert_eq!(env.from, "Jane");
1069 assert!(env.date > 0);
1070 assert_eq!(env.msg_id.as_deref(), Some("<one@x>"));
1071 assert_eq!(env.references, vec!["<root@x>", "<parent@x>"]);
1072 }
1073
1074 #[test]
1075 fn load_collects_brief_and_all_headers() {
1076 let tmp = tempfile::tempdir().unwrap();
1077 let path = tmp.path().join("msg");
1078 std::fs::write(
1079 &path,
1080 "From: a@x\r\nTo: b@y\r\nSubject: s\r\nX-Custom: z\r\n\r\nbody\r\n",
1081 )
1082 .unwrap();
1083 let view = load(&path).unwrap();
1084 assert_eq!(view.brief.len(), 3); assert_eq!(view.all.len(), 4);
1086 assert!(view.all.iter().any(|(k, _)| k == "X-Custom"));
1087 }
1088
1089 fn body_of(raw: &str) -> String {
1090 let tmp = tempfile::tempdir().unwrap();
1091 let path = tmp.path().join("msg");
1092 std::fs::write(&path, raw).unwrap();
1093 load(&path).unwrap().body
1094 }
1095
1096 #[test]
1097 fn render_shows_text_attachments_with_markers() {
1098 let body = body_of(concat!(
1099 "From: a@example.com\r\n",
1100 "MIME-Version: 1.0\r\n",
1101 "Content-Type: multipart/mixed; boundary=\"b\"\r\n",
1102 "\r\n",
1103 "--b\r\n",
1104 "Content-Type: text/plain\r\n",
1105 "\r\n",
1106 "the body\r\n",
1107 "--b\r\n",
1108 "Content-Type: text/plain; name=\"notes.txt\"\r\n",
1109 "Content-Disposition: attachment; filename=\"notes.txt\"\r\n",
1110 "\r\n",
1111 "attached notes\r\n",
1112 "--b--\r\n",
1113 ));
1114 assert!(body.contains("[-- Attachment #1 --]"), "{body}");
1115 assert!(body.contains("the body"), "{body}");
1116 assert!(body.contains("[-- Attachment #2: notes.txt --]"), "{body}");
1117 assert!(
1118 body.contains("[-- Type: text/plain, Encoding: 7bit, Size: 0.0K --]"),
1119 "{body}"
1120 );
1121 assert!(body.contains("attached notes"), "{body}");
1122 }
1123
1124 #[test]
1125 fn render_stubs_non_text_attachments() {
1126 let body = body_of(MULTIPART);
1127 assert!(body.contains("plain text"), "{body}");
1128 assert!(body.contains("[-- Attachment #2: report.pdf --]"), "{body}");
1129 assert!(
1130 body.contains("[-- Type: application/pdf, Encoding: base64, Size: 0.0K --]"),
1131 "{body}"
1132 );
1133 assert!(
1134 body.contains("[-- application/pdf is unsupported (use 'v' to view this part) --]"),
1135 "{body}"
1136 );
1137 }
1138
1139 const ALTERNATIVE: &str = concat!(
1140 "From: a@example.com\r\n",
1141 "MIME-Version: 1.0\r\n",
1142 "Content-Type: multipart/alternative; boundary=\"b\"\r\n",
1143 "\r\n",
1144 "--b\r\n",
1145 "Content-Type: text/plain\r\n",
1146 "\r\n",
1147 "plain version\r\n",
1148 "--b\r\n",
1149 "Content-Type: text/html\r\n",
1150 "\r\n",
1151 "<b>html version</b>\r\n",
1152 "--b--\r\n",
1153 );
1154
1155 const FLOWED: &str = concat!(
1156 "From: a@example.com\r\n",
1157 "Subject: flowed\r\n",
1158 "MIME-Version: 1.0\r\n",
1159 "Content-Type: text/plain; charset=us-ascii; Format=Flowed\r\n",
1160 "\r\n",
1161 "This paragraph was \r\n",
1162 "split by the sender.\r\n",
1163 "\r\n",
1164 "> quoted and \r\n",
1165 "> continued\r\n",
1166 "-- \r\n",
1167 "Jane\r\n",
1168 );
1169
1170 #[test]
1171 fn flowed_parts_come_back_as_paragraphs() {
1172 let body = body_of(FLOWED);
1174 assert!(
1175 body.contains("This paragraph was split by the sender."),
1176 "{body}"
1177 );
1178 assert!(body.contains("> quoted and continued"), "{body}");
1179 assert!(body.contains("-- \nJane"), "{body:?}");
1181 let tmp = tempfile::tempdir().unwrap();
1183 let path = tmp.path().join("msg");
1184 std::fs::write(&path, FLOWED).unwrap();
1185 let plain = load_with(
1186 &path,
1187 &Display {
1188 reflow: false,
1189 ..Display::default()
1190 },
1191 )
1192 .unwrap()
1193 .body;
1194 assert!(plain.contains("This paragraph was \r\nsplit"), "{plain:?}");
1196 }
1197
1198 #[test]
1199 fn alternative_order_outranks_the_text_ranking() {
1200 let tmp = tempfile::tempdir().unwrap();
1201 let path = tmp.path().join("msg");
1202 std::fs::write(&path, ALTERNATIVE).unwrap();
1203 let order = |types: &[&str]| Display {
1204 alternative_order: types.iter().map(|t| t.to_string()).collect(),
1205 ..Display::default()
1206 };
1207 let body = load_with(&path, &order(&["text/html"])).unwrap().body;
1209 assert!(body.contains("<b>html version</b>"), "{body}");
1210 assert!(!body.contains("plain version"), "{body}");
1211 let body = load_with(&path, &order(&["text/enriched", "text/plain"]))
1213 .unwrap()
1214 .body;
1215 assert!(body.contains("plain version"), "{body}");
1216 let body = load_with(&path, &order(&["text/*"])).unwrap().body;
1218 assert!(body.contains("plain version"), "{body}");
1219 let body = load_with(&path, &order(&["application/pdf"])).unwrap().body;
1221 assert!(body.contains("plain version"), "{body}");
1222 let body = load_with(
1224 &path,
1225 &Display {
1226 filters: std::collections::HashMap::from([(
1227 "text/html".to_string(),
1228 "cat".to_string(),
1229 )]),
1230 alternative_order: vec!["text/plain".into()],
1231 ..Display::default()
1232 },
1233 )
1234 .unwrap()
1235 .body;
1236 assert!(body.contains("plain version"), "{body}");
1237 }
1238
1239 #[test]
1240 fn render_entity_shows_the_whole_tree() {
1241 let entity = concat!(
1244 "Content-Type: multipart/mixed; boundary=\"m\"\r\n",
1245 "\r\n",
1246 "--m\r\n",
1247 "Content-Type: text/plain\r\n",
1248 "\r\n",
1249 "the secret plan\r\n",
1250 "--m\r\n",
1251 "Content-Type: application/pdf\r\n",
1252 "Content-Disposition: attachment; filename=\"plan.pdf\"\r\n",
1253 "Content-Transfer-Encoding: base64\r\n",
1254 "\r\n",
1255 "cGxhbg==\r\n",
1256 "--m--\r\n",
1257 );
1258 let body = render_entity(entity.as_bytes(), &Display::default());
1259 assert!(body.contains("the secret plan"), "{body}");
1260 assert!(body.contains("[-- Attachment #2: plan.pdf --]"), "{body}");
1261 assert_eq!(
1263 render_entity(b"just words", &Display::default()),
1264 "just words"
1265 );
1266 }
1267
1268 #[test]
1269 fn render_alternative_prefers_plain_but_autoview_wins() {
1270 let body = body_of(ALTERNATIVE);
1272 assert!(body.contains("plain version"), "{body}");
1273 assert!(!body.contains("html version"), "{body}");
1274 assert!(!body.contains("Attachment #"), "{body}");
1275 let tmp = tempfile::tempdir().unwrap();
1277 let path = tmp.path().join("msg");
1278 std::fs::write(&path, ALTERNATIVE).unwrap();
1279 let filters =
1280 std::collections::HashMap::from([("text/html".to_string(), "cat".to_string())]);
1281 let body = load_with(
1282 &path,
1283 &Display {
1284 filters,
1285 ..Display::default()
1286 },
1287 )
1288 .unwrap()
1289 .body;
1290 assert!(body.contains("[-- Autoview using cat --]"), "{body}");
1291 assert!(body.contains("<b>html version</b>"), "{body}");
1292 assert!(!body.contains("plain version"), "{body}");
1293 }
1294
1295 #[test]
1296 fn render_rfc822_shows_embedded_headers_and_body() {
1297 let body = body_of(concat!(
1298 "From: a@example.com\r\n",
1299 "MIME-Version: 1.0\r\n",
1300 "Content-Type: multipart/mixed; boundary=\"b\"\r\n",
1301 "\r\n",
1302 "--b\r\n",
1303 "Content-Type: text/plain\r\n",
1304 "\r\n",
1305 "see below\r\n",
1306 "--b\r\n",
1307 "Content-Type: message/rfc822\r\n",
1308 "\r\n",
1309 "From: jane@example.com\r\n",
1310 "Subject: inner\r\n",
1311 "\r\n",
1312 "inner body\r\n",
1313 "--b--\r\n",
1314 ));
1315 assert!(body.contains("[-- Attachment #2 --]"), "{body}");
1316 assert!(body.contains("[-- Type: message/rfc822"), "{body}");
1317 assert!(body.contains("From: jane@example.com"), "{body}");
1318 assert!(body.contains("Subject: inner"), "{body}");
1319 assert!(body.contains("inner body"), "{body}");
1320 }
1321
1322 #[test]
1323 fn thread_headers_are_replaced_whole() {
1324 let raw = b"From: a@x\r\nReferences: <a@x>\r\n <b@x>\r\nSubject: s\r\nIn-Reply-To: <b@x>\r\n\r\nbody\r\n";
1325 let out = with_thread_headers(raw, None, &[], false);
1326 assert_eq!(out, b"From: a@x\r\nSubject: s\r\n\r\nbody\r\n");
1327 let broken = with_thread_headers(raw, None, &[], true);
1330 assert_eq!(
1331 broken,
1332 b"From: a@x\r\nSubject: s\r\nX-Rmut-Thread: broken\r\n\r\nbody\r\n"
1333 );
1334 let out = with_thread_headers(
1335 &broken,
1336 Some("<p@x>"),
1337 &["<r@x>".into(), "<p@x>".into()],
1338 false,
1339 );
1340 assert_eq!(
1341 out,
1342 b"From: a@x\r\nSubject: s\r\nIn-Reply-To: <p@x>\r\nReferences: <r@x> <p@x>\r\n\r\nbody\r\n"
1343 );
1344 let out = with_thread_headers(b"From: a@x\nSubject: s\n", Some("<p@x>"), &[], false);
1346 assert_eq!(out, b"From: a@x\nSubject: s\nIn-Reply-To: <p@x>\n");
1347 }
1348}