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