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