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 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
83pub 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
98fn 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 } 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
182pub 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
204fn 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
219fn 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
236pub 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
250pub 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
279pub 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#[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#[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 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
330pub 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#[derive(Debug, Clone)]
357pub struct Display {
358 pub filters: std::collections::HashMap<String, String>,
361 pub rules: HeaderRules,
362 pub reflow: bool,
365 pub alternative_order: Vec<String>,
369 pub html_to_text: bool,
373}
374
375impl Default for Display {
376 fn default() -> Display {
377 Display {
378 filters: std::collections::HashMap::new(),
379 rules: HeaderRules::default(),
380 reflow: true,
381 alternative_order: Vec::new(),
382 html_to_text: true,
383 }
384 }
385}
386
387pub fn load(path: &Path) -> Result<MessageView> {
388 load_with(path, &Display::default())
389}
390
391pub fn load_with(path: &Path, disp: &Display) -> Result<MessageView> {
393 let raw = fs::read(path).with_context(|| format!("reading {}", path.display()))?;
394 let mail = parse_mail(&raw).with_context(|| format!("parsing {}", path.display()))?;
395 let all: Vec<(String, String)> = mail
396 .headers
397 .iter()
398 .map(|h| (h.get_key(), one_line(&h.get_value())))
399 .collect();
400 let brief = weed(&all, &disp.rules);
401 let mut body = String::new();
402 if !render(&mail, disp, &mut body) && body.is_empty() {
403 body = "[-- no displayable text part --]".into();
404 }
405 Ok(MessageView { brief, all, body })
406}
407
408pub fn render_entity(raw: &[u8], disp: &Display) -> String {
413 let Ok(mail) = parse_mail(raw) else {
414 return String::from_utf8_lossy(raw).into_owned();
415 };
416 let mut out = String::new();
417 if !render(&mail, disp, &mut out) || out.trim().is_empty() {
420 return String::from_utf8_lossy(raw).into_owned();
421 }
422 out
423}
424
425fn render(part: &ParsedMail, disp: &Display, out: &mut String) -> bool {
435 let ty = part.ctype.mimetype.clone();
436 if ty == "multipart/alternative" {
437 return match pick_alternative(&part.subparts, disp) {
438 Some(best) => render(best, disp, out),
439 None => {
440 gap(out);
441 out.push_str("[-- multipart/alternative: no displayable part --]\n");
442 false
443 }
444 };
445 }
446 if ty.starts_with("multipart/") {
447 let mut shown = false;
448 for (i, sub) in part.subparts.iter().enumerate() {
449 marker(sub, i + 1, out);
450 shown |= render(sub, disp, out);
451 }
452 return shown;
453 }
454 if let Some(command) = disp.filters.get(&ty) {
455 gap(out);
456 let text = part
457 .get_body_raw()
458 .map_err(anyhow::Error::from)
459 .and_then(|raw| run_filter(command, &raw));
460 return match text {
461 Ok(text) => {
462 out.push_str(&format!("[-- Autoview using {command} --]\n\n{text}"));
463 ensure_newline(out);
464 true
465 }
466 Err(err) => {
467 out.push_str(&format!("[-- filter {command} failed: {err:#} --]\n"));
468 false
469 }
470 };
471 }
472 if ty == "message/rfc822" {
473 if let Ok(raw) = part.get_body_raw()
474 && let Ok(embedded) = parse_mail(&raw)
475 {
476 gap(out);
477 let all: Vec<(String, String)> = embedded
478 .headers
479 .iter()
480 .map(|h| (h.get_key(), h.get_value()))
481 .collect();
482 for (name, value) in weed(&all, &disp.rules) {
483 out.push_str(&format!("{name}: {value}\n"));
484 }
485 out.push('\n');
486 return render(&embedded, disp, out);
487 }
488 gap(out);
489 out.push_str("[-- message/rfc822: cannot parse --]\n");
490 return false;
491 }
492 if ty.starts_with("text/")
493 && let Ok(text) = part.get_body()
494 {
495 gap(out);
496 if ty == "text/html" && disp.html_to_text {
497 out.push_str(&crate::html::to_text(&text));
500 ensure_newline(out);
501 return true;
502 }
503 match flowed_delsp(part).filter(|_| disp.reflow) {
507 Some(delsp) => out.push_str(&crate::flowed::unflow(&text, delsp)),
508 None => out.push_str(&text),
509 }
510 ensure_newline(out);
511 return true;
512 }
513 gap(out);
514 out.push_str(&format!(
515 "[-- {ty} is unsupported (use 'v' to view this part) --]\n"
516 ));
517 false
518}
519
520fn flowed_delsp(part: &ParsedMail) -> Option<bool> {
523 let value = |name: &str| {
524 part.ctype
525 .params
526 .iter()
527 .find(|(k, _)| k.eq_ignore_ascii_case(name))
528 .map(|(_, v)| v.trim().to_lowercase())
529 };
530 if part.ctype.mimetype != "text/plain" || value("format").as_deref() != Some("flowed") {
531 return None;
532 }
533 Some(value("delsp").as_deref() == Some("yes"))
534}
535
536fn pick_alternative<'a, 'b>(
541 subs: &'a [ParsedMail<'b>],
542 disp: &Display,
543) -> Option<&'a ParsedMail<'b>> {
544 for want in &disp.alternative_order {
545 if let Some(p) = subs
546 .iter()
547 .find(|p| type_matches(want, &p.ctype.mimetype) && displayable(p, &disp.filters))
548 {
549 return Some(p);
550 }
551 }
552 if let Some(p) = subs
553 .iter()
554 .rev()
555 .find(|p| disp.filters.contains_key(&p.ctype.mimetype))
556 {
557 return Some(p);
558 }
559 let rank = |p: &ParsedMail| match p.ctype.mimetype.as_str() {
560 "text/enriched" => 3,
561 "text/plain" => 2,
562 "text/html" => 1,
563 _ => 0,
564 };
565 if let Some((_, p)) = subs
566 .iter()
567 .enumerate()
568 .filter(|(_, p)| rank(p) > 0)
569 .max_by_key(|&(i, p)| (rank(p), i))
570 {
571 return Some(p);
572 }
573 subs.iter().find(|p| displayable(p, &disp.filters))
574}
575
576fn type_matches(want: &str, mimetype: &str) -> bool {
579 let want = want.trim().to_lowercase();
580 if want.is_empty() {
581 return false;
582 }
583 match want.strip_suffix("/*").unwrap_or(&want) {
584 main if main == want && want.contains('/') => want == mimetype,
585 main => mimetype.split('/').next() == Some(main),
586 }
587}
588
589fn displayable(part: &ParsedMail, filters: &std::collections::HashMap<String, String>) -> bool {
591 let ty = &part.ctype.mimetype;
592 ty.starts_with("text/")
593 || ty == "message/rfc822"
594 || filters.contains_key(ty)
595 || (ty.starts_with("multipart/") && part.subparts.iter().any(|s| displayable(s, filters)))
596}
597
598fn marker(part: &ParsedMail, count: usize, out: &mut String) {
602 gap(out);
603 let name = part
604 .get_headers()
605 .get_first_value("Content-Description")
606 .or_else(|| part_filename(part));
607 match name {
608 Some(n) => out.push_str(&format!("[-- Attachment #{count}: {n} --]\n")),
609 None => out.push_str(&format!("[-- Attachment #{count} --]\n")),
610 }
611 let encoding = part
612 .get_headers()
613 .get_first_value("Content-Transfer-Encoding")
614 .map(|e| e.to_lowercase())
615 .unwrap_or_else(|| "7bit".into());
616 let size = part.get_body_raw().map(|b| b.len()).unwrap_or(0);
617 out.push_str(&format!(
618 "[-- Type: {}, Encoding: {encoding}, Size: {} --]\n",
619 part.ctype.mimetype,
620 pretty_size(size)
621 ));
622}
623
624fn gap(out: &mut String) {
626 if out.is_empty() {
627 return;
628 }
629 while !out.ends_with("\n\n") {
630 out.push('\n');
631 }
632}
633
634fn ensure_newline(out: &mut String) {
635 if !out.ends_with('\n') {
636 out.push('\n');
637 }
638}
639
640fn pretty_size(n: usize) -> String {
642 if n == 0 {
643 "0K".into()
644 } else if n < 10189 {
645 format!("{:.1}K", n as f64 / 1024.0)
646 } else if n < 1023949 {
647 format!("{}K", (n + 51) / 1024)
648 } else if n < 10433332 {
649 format!("{:.1}M", n as f64 / 1048576.0)
650 } else {
651 format!("{}M", n / 1048576)
652 }
653}
654
655pub fn filter_part(path: &Path, index: usize, command: &str) -> Result<String> {
657 let raw = fs::read(path).with_context(|| format!("reading {}", path.display()))?;
658 let mail = parse_mail(&raw)?;
659 let bytes = leaf_at(&mail, index)?.get_body_raw()?;
660 run_filter(command, &bytes)
661}
662
663fn run_filter(command: &str, input: &[u8]) -> Result<String> {
667 match command.contains("%s") {
668 true => {
669 let file = TempPart::new(input)?;
670 let quoted = format!(
671 "'{}'",
672 file.path.display().to_string().replace('\'', r"'\''")
673 );
674 run_piped(&command.replace("%s", "ed), b"")
675 }
676 false => run_piped(command, input),
677 }
678}
679
680struct TempPart {
682 path: std::path::PathBuf,
683}
684
685impl TempPart {
686 fn new(input: &[u8]) -> Result<TempPart> {
687 static N: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
688 let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
689 let path = std::env::temp_dir().join(format!("rmut-part-{}-{n}", std::process::id()));
690 fs::write(&path, input).with_context(|| format!("writing {}", path.display()))?;
691 Ok(TempPart { path })
692 }
693}
694
695impl Drop for TempPart {
696 fn drop(&mut self) {
697 let _ = fs::remove_file(&self.path);
698 }
699}
700
701fn run_piped(command: &str, input: &[u8]) -> Result<String> {
702 use std::io::Write as _;
703 let mut child = std::process::Command::new("sh")
704 .arg("-c")
705 .arg(command)
706 .stdin(std::process::Stdio::piped())
707 .stdout(std::process::Stdio::piped())
708 .stderr(std::process::Stdio::null())
709 .spawn()
710 .with_context(|| format!("running {command}"))?;
711 let mut stdin = child.stdin.take().context("no stdin on filter child")?;
712 let input = input.to_vec();
713 let writer = std::thread::spawn(move || {
714 let _ = stdin.write_all(&input);
715 });
716 let out = child.wait_with_output()?;
717 let _ = writer.join();
718 anyhow::ensure!(out.status.success(), "{command} exited with {}", out.status);
719 Ok(String::from_utf8_lossy(&out.stdout).into_owned())
720}
721
722pub fn with_header(raw: &[u8], name: &str, value: Option<&str>) -> Vec<u8> {
728 let (head, body) = match raw.windows(4).position(|w| w == b"\r\n\r\n") {
729 Some(at) => (&raw[..at + 2], &raw[at + 2..]),
730 None => match raw.windows(2).position(|w| w == b"\n\n") {
731 Some(at) => (&raw[..at + 1], &raw[at + 1..]),
732 None => (raw, &raw[raw.len()..]),
733 },
734 };
735 let crlf = head.windows(2).any(|w| w == b"\r\n");
736 let eol: &[u8] = if crlf { b"\r\n" } else { b"\n" };
737 let prefix = format!("{}:", name.to_ascii_lowercase());
738 let mut out = Vec::with_capacity(raw.len() + name.len() + 32);
739 let mut skipping = false;
740 for line in head.split_inclusive(|&b| b == b'\n') {
741 let folded = line.first().is_some_and(|b| *b == b' ' || *b == b'\t');
742 if folded && skipping {
743 continue;
744 }
745 let lower: Vec<u8> = line
746 .iter()
747 .take(prefix.len())
748 .map(u8::to_ascii_lowercase)
749 .collect();
750 skipping = lower == prefix.as_bytes();
751 if !skipping {
752 out.extend_from_slice(line);
753 }
754 }
755 if let Some(v) = value.filter(|v| !v.trim().is_empty()) {
756 out.extend_from_slice(name.as_bytes());
757 out.extend_from_slice(b": ");
758 out.extend_from_slice(v.as_bytes());
759 out.extend_from_slice(eol);
760 }
761 out.extend_from_slice(body);
762 out
763}
764
765pub fn with_thread_headers(
773 raw: &[u8],
774 in_reply_to: Option<&str>,
775 references: &[String],
776 broken: bool,
777) -> Vec<u8> {
778 let (head, body) = match raw.windows(4).position(|w| w == b"\r\n\r\n") {
779 Some(at) => (&raw[..at + 2], &raw[at + 2..]),
780 None => match raw.windows(2).position(|w| w == b"\n\n") {
781 Some(at) => (&raw[..at + 1], &raw[at + 1..]),
782 None => (raw, &raw[raw.len()..]),
783 },
784 };
785 let crlf = head.windows(2).any(|w| w == b"\r\n");
786 let eol: &[u8] = if crlf { b"\r\n" } else { b"\n" };
787 let mut out = Vec::with_capacity(raw.len() + 128);
788 let mut skipping = false;
789 for line in head.split_inclusive(|&b| b == b'\n') {
790 let folded = line.first().is_some_and(|b| *b == b' ' || *b == b'\t');
791 if folded && skipping {
792 continue;
793 }
794 let lower: Vec<u8> = line.iter().take(14).map(u8::to_ascii_lowercase).collect();
795 skipping = lower.starts_with(b"in-reply-to:")
796 || lower.starts_with(b"references:")
797 || lower.starts_with(b"x-rmut-thread:");
798 if !skipping {
799 out.extend_from_slice(line);
800 }
801 }
802 if let Some(id) = in_reply_to {
803 out.extend_from_slice(b"In-Reply-To: ");
804 out.extend_from_slice(id.as_bytes());
805 out.extend_from_slice(eol);
806 }
807 if !references.is_empty() {
808 out.extend_from_slice(b"References: ");
809 out.extend_from_slice(references.join(" ").as_bytes());
810 out.extend_from_slice(eol);
811 }
812 if broken {
813 out.extend_from_slice(format!("{BROKEN_HEADER}: {BROKEN_VALUE}").as_bytes());
814 out.extend_from_slice(eol);
815 }
816 out.extend_from_slice(body);
817 out
818}
819
820pub fn first_header(path: &Path, name: &str) -> Option<String> {
823 let raw = fs::read(path).ok()?;
824 let mail = parse_mail(&raw).ok()?;
825 mail.get_headers().get_first_value(name)
826}
827
828pub fn header_text(path: &Path) -> Option<String> {
831 let raw = fs::read(path).ok()?;
832 let mail = parse_mail(&raw).ok()?;
833 let mut out = String::new();
834 for header in mail.get_headers() {
835 out.push_str(&header.get_key());
836 out.push_str(": ");
837 out.push_str(&header.get_value());
838 out.push('\n');
839 }
840 Some(out)
841}
842
843pub fn body_text(path: &Path) -> Result<String> {
845 let raw = fs::read(path).with_context(|| format!("reading {}", path.display()))?;
846 let mail = parse_mail(&raw).with_context(|| format!("parsing {}", path.display()))?;
847 Ok(extract_text(&mail).unwrap_or_default())
848}
849
850pub(crate) fn extract_text(mail: &ParsedMail) -> Option<String> {
853 if mail.subparts.is_empty() {
854 if mail.ctype.mimetype.starts_with("text/") {
855 return mail.get_body().ok();
856 }
857 return None;
858 }
859 for sub in &mail.subparts {
860 if sub.ctype.mimetype == "text/plain"
861 && sub.subparts.is_empty()
862 && let Ok(body) = sub.get_body()
863 {
864 return Some(body);
865 }
866 }
867 for sub in &mail.subparts {
868 if let Some(body) = extract_text(sub) {
869 return Some(body);
870 }
871 }
872 None
873}
874
875#[derive(Debug, Clone)]
877pub struct Part {
878 pub mimetype: String,
879 pub filename: Option<String>,
880 pub size: usize,
882 pub is_text: bool,
883}
884
885fn leaves<'a, 'b>(mail: &'a ParsedMail<'b>, out: &mut Vec<&'a ParsedMail<'b>>) {
886 if mail.subparts.is_empty() {
887 out.push(mail);
888 } else {
889 for sub in &mail.subparts {
890 leaves(sub, out);
891 }
892 }
893}
894
895pub fn parts(path: &Path) -> Result<Vec<Part>> {
896 let raw = fs::read(path).with_context(|| format!("reading {}", path.display()))?;
897 let mail = parse_mail(&raw).with_context(|| format!("parsing {}", path.display()))?;
898 let mut all = Vec::new();
899 leaves(&mail, &mut all);
900 Ok(all
901 .iter()
902 .map(|p| Part {
903 mimetype: p.ctype.mimetype.clone(),
904 filename: part_filename(p),
905 size: p.get_body_raw().map(|b| b.len()).unwrap_or(0),
906 is_text: p.ctype.mimetype.starts_with("text/"),
907 })
908 .collect())
909}
910
911fn part_filename(p: &ParsedMail) -> Option<String> {
914 p.get_content_disposition()
915 .params
916 .get("filename")
917 .cloned()
918 .or_else(|| p.ctype.params.get("name").cloned())
919 .map(|n| one_line(&n))
920}
921
922fn leaf_at<'a, 'b>(mail: &'a ParsedMail<'b>, index: usize) -> Result<&'a ParsedMail<'b>> {
923 let mut all = Vec::new();
924 leaves(mail, &mut all);
925 all.get(index).copied().context("no such part")
926}
927
928pub fn part_text(path: &Path, index: usize) -> Result<String> {
930 let raw = fs::read(path).with_context(|| format!("reading {}", path.display()))?;
931 let mail = parse_mail(&raw)?;
932 Ok(leaf_at(&mail, index)?.get_body()?)
933}
934
935pub fn part_bytes(path: &Path, index: usize) -> Result<Vec<u8>> {
937 let raw = fs::read(path).with_context(|| format!("reading {}", path.display()))?;
938 let mail = parse_mail(&raw)?;
939 Ok(leaf_at(&mail, index)?.get_body_raw()?)
940}
941
942#[cfg(test)]
943mod tests {
944 use super::*;
945
946 #[test]
947 fn weed_applies_ignore_unignore_and_order() {
948 let all: Vec<(String, String)> = [
949 ("Received", "relay"),
950 ("Subject", "hi"),
951 ("X-Topic", "budget"),
952 ("From", "jane@example.com"),
953 ("X-Spam-Score", "0"),
954 ]
955 .map(|(a, b)| (a.to_string(), b.to_string()))
956 .to_vec();
957 let names = |v: &[(String, String)]| v.iter().map(|(n, _)| n.clone()).collect::<Vec<_>>();
958 assert_eq!(
960 names(&weed(&all, &HeaderRules::default())),
961 ["From", "Subject"]
962 );
963 let rules = HeaderRules {
966 ignore: vec!["x-".into(), "received".into()],
967 unignore: vec!["x-topic".into()],
968 order: vec![],
969 };
970 assert_eq!(names(&weed(&all, &rules)), ["Subject", "X-Topic", "From"]);
971 let rules = HeaderRules {
973 ignore: vec!["*".into()],
974 unignore: vec!["subject".into(), "x-topic".into(), "from".into()],
975 order: vec!["x-topic".into(), "from".into()],
976 };
977 assert_eq!(names(&weed(&all, &rules)), ["X-Topic", "From", "Subject"]);
978 }
979
980 const MULTIPART: &str = concat!(
981 "From: a@example.com\r\n",
982 "Subject: multi\r\n",
983 "MIME-Version: 1.0\r\n",
984 "Content-Type: multipart/mixed; boundary=\"b\"\r\n",
985 "\r\n",
986 "--b\r\n",
987 "Content-Type: text/plain\r\n",
988 "\r\n",
989 "plain text\r\n",
990 "--b\r\n",
991 "Content-Type: application/pdf; name=\"report.pdf\"\r\n",
992 "Content-Disposition: attachment; filename=\"report.pdf\"\r\n",
993 "Content-Transfer-Encoding: base64\r\n",
994 "\r\n",
995 "JVBERg==\r\n",
996 "--b--\r\n",
997 );
998
999 #[test]
1000 fn control_characters_never_reach_a_one_line_field() {
1001 assert_eq!(one_line("before\ttab after"), "before tab after");
1005 assert_eq!(one_line("two\u{7}bells\u{1b}"), "two bells ");
1006 assert_eq!(one_line("nothing to do"), "nothing to do");
1007
1008 let raw = concat!(
1009 "From: Tabbed\tSender <t@example.com>\r\n",
1010 "Subject: before\ttab after\r\n",
1011 "Date: Mon, 6 Jul 2026 10:00:00 +0200\r\n",
1012 "\r\nbody\r\n",
1013 );
1014 let tmp = tempfile::tempdir().unwrap();
1015 let path = tmp.path().join("cur-msg");
1016 std::fs::write(&path, raw).unwrap();
1017 let file = crate::maildir::MailFile {
1018 path: path.clone(),
1019 is_new: false,
1020 flags: Default::default(),
1021 size: raw.len() as u64,
1022 };
1023 let env = envelope(file).unwrap();
1024 assert_eq!(env.subject, "before tab after");
1025 assert_eq!(env.from, "Tabbed Sender");
1026 assert!(!env.from_full.contains('\t'));
1027 let view = load(&path).unwrap();
1029 assert!(
1030 view.all.iter().all(|(_, v)| !v.contains('\t')),
1031 "{:?}",
1032 view.all
1033 );
1034 }
1035
1036 #[test]
1037 fn short_from_prefers_display_name() {
1038 assert_eq!(short_from("Jane Doe <jane@example.com>"), "Jane Doe");
1039 assert_eq!(short_from("jane@example.com"), "jane@example.com");
1040 assert_eq!(short_from(""), "");
1041 }
1042
1043 #[test]
1044 fn extract_text_picks_plain_from_multipart() {
1045 let mail = parse_mail(MULTIPART.as_bytes()).unwrap();
1046 assert_eq!(extract_text(&mail).unwrap().trim(), "plain text");
1047 }
1048
1049 #[test]
1050 fn parts_lists_leaves_with_filenames() {
1051 let tmp = tempfile::tempdir().unwrap();
1052 let path = tmp.path().join("msg");
1053 std::fs::write(&path, MULTIPART).unwrap();
1054 let parts = parts(&path).unwrap();
1055 assert_eq!(parts.len(), 2);
1056 assert!(parts[0].is_text && parts[0].filename.is_none());
1057 assert_eq!(parts[1].mimetype, "application/pdf");
1058 assert_eq!(parts[1].filename.as_deref(), Some("report.pdf"));
1059 assert_eq!(part_bytes(&path, 1).unwrap(), b"%PDF");
1061 assert_eq!(part_text(&path, 0).unwrap().trim(), "plain text");
1062 }
1063
1064 #[test]
1065 fn parse_msg_ids_handles_lists_and_garbage() {
1066 assert_eq!(parse_msg_ids("<a@x> <b@y>"), vec!["<a@x>", "<b@y>"]);
1067 assert_eq!(parse_msg_ids("junk <a@x> junk"), vec!["<a@x>"]);
1068 assert!(parse_msg_ids("no ids here <broken").is_empty());
1069 }
1070
1071 #[test]
1072 fn envelope_counts_lines_and_finds_the_list() {
1073 let tmp = tempfile::tempdir().unwrap();
1074 let file = |name: &str, content: &str| {
1075 let path = tmp.path().join(name);
1076 std::fs::write(&path, content).unwrap();
1077 envelope(crate::maildir::MailFile {
1078 path,
1079 is_new: false,
1080 flags: Default::default(),
1081 size: 0,
1082 })
1083 .unwrap()
1084 };
1085 let env = file(
1086 "listed",
1087 "From: a@x\r\nList-Id: Dev talk <dev.lists.example.com>\r\nSubject: s\r\n\r\none\r\ntwo\r\nthree",
1088 );
1089 assert_eq!(env.lines, Some(3)); assert_eq!(env.list.as_deref(), Some("Dev talk"));
1091 let env = file(
1092 "bare-list",
1093 "From: a@x\r\nList-Id: <announce.example.com>\r\nSubject: s\r\n\r\nhi\r\n",
1094 );
1095 assert_eq!(env.list.as_deref(), Some("announce"));
1096 assert_eq!(env.lines, Some(1));
1097 let env = file("plain", "From: a@x\r\nSubject: s\r\n\r\n");
1098 assert!(env.list.is_none());
1099 assert_eq!(env.lines, Some(0));
1100 let env = file(
1102 "partial",
1103 "X-Rmut-Partial: 1\r\nFrom: a@x\r\nSubject: s\r\n\r\n",
1104 );
1105 assert_eq!(env.lines, None);
1106 }
1107
1108 #[test]
1109 fn envelope_decodes_rfc2047_subject() {
1110 let tmp = tempfile::tempdir().unwrap();
1111 let path = tmp.path().join("msg");
1112 std::fs::write(
1113 &path,
1114 "From: Jane <j@example.com>\r\nSubject: =?utf-8?q?p=C5=99=C3=ADli=C5=A1?=\r\nDate: Mon, 6 Jul 2026 10:00:00 +0200\r\nMessage-ID: <one@x>\r\nReferences: <root@x>\r\nIn-Reply-To: <parent@x>\r\n\r\nhi\r\n",
1115 )
1116 .unwrap();
1117 let env = envelope(crate::maildir::MailFile {
1118 path,
1119 is_new: true,
1120 flags: Default::default(),
1121 size: 0,
1122 })
1123 .unwrap();
1124 assert_eq!(env.subject, "příliš");
1125 assert_eq!(env.from, "Jane");
1126 assert!(env.date > 0);
1127 assert_eq!(env.msg_id.as_deref(), Some("<one@x>"));
1128 assert_eq!(env.references, vec!["<root@x>", "<parent@x>"]);
1129 }
1130
1131 #[test]
1132 fn load_collects_brief_and_all_headers() {
1133 let tmp = tempfile::tempdir().unwrap();
1134 let path = tmp.path().join("msg");
1135 std::fs::write(
1136 &path,
1137 "From: a@x\r\nTo: b@y\r\nSubject: s\r\nX-Custom: z\r\n\r\nbody\r\n",
1138 )
1139 .unwrap();
1140 let view = load(&path).unwrap();
1141 assert_eq!(view.brief.len(), 3); assert_eq!(view.all.len(), 4);
1143 assert!(view.all.iter().any(|(k, _)| k == "X-Custom"));
1144 }
1145
1146 fn body_of(raw: &str) -> String {
1147 let tmp = tempfile::tempdir().unwrap();
1148 let path = tmp.path().join("msg");
1149 std::fs::write(&path, raw).unwrap();
1150 load(&path).unwrap().body
1151 }
1152
1153 #[test]
1154 fn render_shows_text_attachments_with_markers() {
1155 let body = body_of(concat!(
1156 "From: a@example.com\r\n",
1157 "MIME-Version: 1.0\r\n",
1158 "Content-Type: multipart/mixed; boundary=\"b\"\r\n",
1159 "\r\n",
1160 "--b\r\n",
1161 "Content-Type: text/plain\r\n",
1162 "\r\n",
1163 "the body\r\n",
1164 "--b\r\n",
1165 "Content-Type: text/plain; name=\"notes.txt\"\r\n",
1166 "Content-Disposition: attachment; filename=\"notes.txt\"\r\n",
1167 "\r\n",
1168 "attached notes\r\n",
1169 "--b--\r\n",
1170 ));
1171 assert!(body.contains("[-- Attachment #1 --]"), "{body}");
1172 assert!(body.contains("the body"), "{body}");
1173 assert!(body.contains("[-- Attachment #2: notes.txt --]"), "{body}");
1174 assert!(
1175 body.contains("[-- Type: text/plain, Encoding: 7bit, Size: 0.0K --]"),
1176 "{body}"
1177 );
1178 assert!(body.contains("attached notes"), "{body}");
1179 }
1180
1181 #[test]
1182 fn render_stubs_non_text_attachments() {
1183 let body = body_of(MULTIPART);
1184 assert!(body.contains("plain text"), "{body}");
1185 assert!(body.contains("[-- Attachment #2: report.pdf --]"), "{body}");
1186 assert!(
1187 body.contains("[-- Type: application/pdf, Encoding: base64, Size: 0.0K --]"),
1188 "{body}"
1189 );
1190 assert!(
1191 body.contains("[-- application/pdf is unsupported (use 'v' to view this part) --]"),
1192 "{body}"
1193 );
1194 }
1195
1196 const ALTERNATIVE: &str = concat!(
1197 "From: a@example.com\r\n",
1198 "MIME-Version: 1.0\r\n",
1199 "Content-Type: multipart/alternative; boundary=\"b\"\r\n",
1200 "\r\n",
1201 "--b\r\n",
1202 "Content-Type: text/plain\r\n",
1203 "\r\n",
1204 "plain version\r\n",
1205 "--b\r\n",
1206 "Content-Type: text/html\r\n",
1207 "\r\n",
1208 "<b>html version</b>\r\n",
1209 "--b--\r\n",
1210 );
1211
1212 const FLOWED: &str = concat!(
1213 "From: a@example.com\r\n",
1214 "Subject: flowed\r\n",
1215 "MIME-Version: 1.0\r\n",
1216 "Content-Type: text/plain; charset=us-ascii; Format=Flowed\r\n",
1217 "\r\n",
1218 "This paragraph was \r\n",
1219 "split by the sender.\r\n",
1220 "\r\n",
1221 "> quoted and \r\n",
1222 "> continued\r\n",
1223 "-- \r\n",
1224 "Jane\r\n",
1225 );
1226
1227 #[test]
1228 fn flowed_parts_come_back_as_paragraphs() {
1229 let body = body_of(FLOWED);
1231 assert!(
1232 body.contains("This paragraph was split by the sender."),
1233 "{body}"
1234 );
1235 assert!(body.contains("> quoted and continued"), "{body}");
1236 assert!(body.contains("-- \nJane"), "{body:?}");
1238 let tmp = tempfile::tempdir().unwrap();
1240 let path = tmp.path().join("msg");
1241 std::fs::write(&path, FLOWED).unwrap();
1242 let plain = load_with(
1243 &path,
1244 &Display {
1245 reflow: false,
1246 ..Display::default()
1247 },
1248 )
1249 .unwrap()
1250 .body;
1251 assert!(plain.contains("This paragraph was \r\nsplit"), "{plain:?}");
1253 }
1254
1255 #[test]
1256 fn alternative_order_outranks_the_text_ranking() {
1257 let tmp = tempfile::tempdir().unwrap();
1258 let path = tmp.path().join("msg");
1259 std::fs::write(&path, ALTERNATIVE).unwrap();
1260 let order = |types: &[&str]| Display {
1261 alternative_order: types.iter().map(|t| t.to_string()).collect(),
1262 ..Display::default()
1263 };
1264 let body = load_with(&path, &order(&["text/html"])).unwrap().body;
1268 assert!(body.contains("html version"), "{body}");
1269 assert!(!body.contains("<b>"), "{body}");
1270 assert!(!body.contains("plain version"), "{body}");
1271 let raw = Display {
1272 html_to_text: false,
1273 ..order(&["text/html"])
1274 };
1275 let body = load_with(&path, &raw).unwrap().body;
1276 assert!(body.contains("<b>html version</b>"), "{body}");
1277 let body = load_with(&path, &order(&["text/enriched", "text/plain"]))
1279 .unwrap()
1280 .body;
1281 assert!(body.contains("plain version"), "{body}");
1282 let body = load_with(&path, &order(&["text/*"])).unwrap().body;
1284 assert!(body.contains("plain version"), "{body}");
1285 let body = load_with(&path, &order(&["application/pdf"])).unwrap().body;
1287 assert!(body.contains("plain version"), "{body}");
1288 let body = load_with(
1290 &path,
1291 &Display {
1292 filters: std::collections::HashMap::from([(
1293 "text/html".to_string(),
1294 "cat".to_string(),
1295 )]),
1296 alternative_order: vec!["text/plain".into()],
1297 ..Display::default()
1298 },
1299 )
1300 .unwrap()
1301 .body;
1302 assert!(body.contains("plain version"), "{body}");
1303 }
1304
1305 #[test]
1306 fn render_entity_shows_the_whole_tree() {
1307 let entity = concat!(
1310 "Content-Type: multipart/mixed; boundary=\"m\"\r\n",
1311 "\r\n",
1312 "--m\r\n",
1313 "Content-Type: text/plain\r\n",
1314 "\r\n",
1315 "the secret plan\r\n",
1316 "--m\r\n",
1317 "Content-Type: application/pdf\r\n",
1318 "Content-Disposition: attachment; filename=\"plan.pdf\"\r\n",
1319 "Content-Transfer-Encoding: base64\r\n",
1320 "\r\n",
1321 "cGxhbg==\r\n",
1322 "--m--\r\n",
1323 );
1324 let body = render_entity(entity.as_bytes(), &Display::default());
1325 assert!(body.contains("the secret plan"), "{body}");
1326 assert!(body.contains("[-- Attachment #2: plan.pdf --]"), "{body}");
1327 assert_eq!(
1329 render_entity(b"just words", &Display::default()),
1330 "just words"
1331 );
1332 }
1333
1334 #[test]
1335 fn render_alternative_prefers_plain_but_autoview_wins() {
1336 let body = body_of(ALTERNATIVE);
1338 assert!(body.contains("plain version"), "{body}");
1339 assert!(!body.contains("html version"), "{body}");
1340 assert!(!body.contains("Attachment #"), "{body}");
1341 let tmp = tempfile::tempdir().unwrap();
1343 let path = tmp.path().join("msg");
1344 std::fs::write(&path, ALTERNATIVE).unwrap();
1345 let filters =
1346 std::collections::HashMap::from([("text/html".to_string(), "cat".to_string())]);
1347 let body = load_with(
1348 &path,
1349 &Display {
1350 filters,
1351 ..Display::default()
1352 },
1353 )
1354 .unwrap()
1355 .body;
1356 assert!(body.contains("[-- Autoview using cat --]"), "{body}");
1357 assert!(body.contains("<b>html version</b>"), "{body}");
1358 assert!(!body.contains("plain version"), "{body}");
1359 }
1360
1361 #[test]
1362 fn render_rfc822_shows_embedded_headers_and_body() {
1363 let body = body_of(concat!(
1364 "From: a@example.com\r\n",
1365 "MIME-Version: 1.0\r\n",
1366 "Content-Type: multipart/mixed; boundary=\"b\"\r\n",
1367 "\r\n",
1368 "--b\r\n",
1369 "Content-Type: text/plain\r\n",
1370 "\r\n",
1371 "see below\r\n",
1372 "--b\r\n",
1373 "Content-Type: message/rfc822\r\n",
1374 "\r\n",
1375 "From: jane@example.com\r\n",
1376 "Subject: inner\r\n",
1377 "\r\n",
1378 "inner body\r\n",
1379 "--b--\r\n",
1380 ));
1381 assert!(body.contains("[-- Attachment #2 --]"), "{body}");
1382 assert!(body.contains("[-- Type: message/rfc822"), "{body}");
1383 assert!(body.contains("From: jane@example.com"), "{body}");
1384 assert!(body.contains("Subject: inner"), "{body}");
1385 assert!(body.contains("inner body"), "{body}");
1386 }
1387
1388 #[test]
1389 fn thread_headers_are_replaced_whole() {
1390 let raw = b"From: a@x\r\nReferences: <a@x>\r\n <b@x>\r\nSubject: s\r\nIn-Reply-To: <b@x>\r\n\r\nbody\r\n";
1391 let out = with_thread_headers(raw, None, &[], false);
1392 assert_eq!(out, b"From: a@x\r\nSubject: s\r\n\r\nbody\r\n");
1393 let broken = with_thread_headers(raw, None, &[], true);
1396 assert_eq!(
1397 broken,
1398 b"From: a@x\r\nSubject: s\r\nX-Rmut-Thread: broken\r\n\r\nbody\r\n"
1399 );
1400 let out = with_thread_headers(
1401 &broken,
1402 Some("<p@x>"),
1403 &["<r@x>".into(), "<p@x>".into()],
1404 false,
1405 );
1406 assert_eq!(
1407 out,
1408 b"From: a@x\r\nSubject: s\r\nIn-Reply-To: <p@x>\r\nReferences: <r@x> <p@x>\r\n\r\nbody\r\n"
1409 );
1410 let out = with_thread_headers(b"From: a@x\nSubject: s\n", Some("<p@x>"), &[], false);
1412 assert_eq!(out, b"From: a@x\nSubject: s\nIn-Reply-To: <p@x>\n");
1413 }
1414
1415 #[test]
1416 fn list_actions_take_the_first_mailto_of_each_header() {
1417 let raw = b"From: a@x\r\nList-Id: <dev.example.com>\r\n\
1418List-Unsubscribe: <https://lists.example.com/leave>, <mailto:dev-leave@example.com?subject=x>\r\n\
1419List-Help: <https://lists.example.com/help>\r\nSubject: s\r\n\r\nbody\r\n";
1420 let actions = list_actions(raw);
1421 assert_eq!(actions.len(), 6);
1422 assert_eq!(
1423 actions[3],
1424 (
1425 "Unsubscribe",
1426 Some("mailto:dev-leave@example.com?subject=x".to_string())
1427 ),
1428 "the mailto wins over the https that came first"
1429 );
1430 assert_eq!(
1431 actions[0],
1432 ("Help", Some("https://lists.example.com/help".to_string())),
1433 "no mailto: the first URL, so the refusal can name it"
1434 );
1435 assert_eq!(actions[1], ("Post", None));
1436 }
1437}