1use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result, ensure};
6use chrono::{Local, TimeZone};
7
8use crate::pattern::Me;
9
10pub struct DraftHeaders {
11 pub from: Option<String>,
15 pub to: String,
16 pub cc: Option<String>,
17 pub subject: String,
18 pub in_reply_to: Option<String>,
19 pub references: Option<String>,
20}
21
22pub fn draft_text(h: &DraftHeaders, body: &str) -> String {
24 let mut out = String::new();
25 if let Some(from) = &h.from {
26 out += &format!("From: {from}\n");
27 }
28 out += &format!("To: {}\n", h.to);
29 if let Some(cc) = &h.cc
30 && !cc.trim().is_empty()
31 {
32 out += &format!("Cc: {cc}\n");
33 }
34 out += &format!("Subject: {}\n", h.subject);
35 if let Some(x) = &h.in_reply_to {
36 out += &format!("In-Reply-To: {x}\n");
37 }
38 if let Some(x) = &h.references {
39 out += &format!("References: {x}\n");
40 }
41 out.push('\n');
42 out.push_str(body);
43 if !out.ends_with('\n') {
44 out.push('\n');
45 }
46 out
47}
48
49pub const DEFAULT_ATTRIBUTION: &str = "On %d, %n wrote:";
51
52pub const DEFAULT_FORWARD_FORMAT: &str = "[%a: %s]";
54
55pub const DEFAULT_INDENT: &str = "> ";
57
58pub struct Quoted<'a> {
61 pub from: &'a str,
63 pub subject: &'a str,
64 pub message_id: Option<&'a str>,
65 pub date: i64,
67}
68
69impl Quoted<'_> {
70 fn name(&self) -> String {
73 let trimmed = self.from.trim();
74 let name = match trimmed.split_once('<') {
75 Some((name, _)) => name.trim().trim_matches('"').trim(),
76 None => "",
77 };
78 match name.is_empty() {
79 true => self.address(),
80 false => name.to_string(),
81 }
82 }
83
84 fn address(&self) -> String {
85 bare_address(self.from).unwrap_or_else(|| self.from.trim().to_string())
86 }
87}
88
89pub fn render_quoted(fmt: &str, m: &Quoted) -> String {
94 let fmt = expand_strftime(fmt, m.date);
97 crate::format::render_with(&fmt, &|spec| match spec {
98 'a' => m.address(),
99 'n' => m.name(),
100 'f' => m.from.trim().to_string(),
101 's' => m.subject.trim().to_string(),
102 'i' => m
103 .message_id
104 .unwrap_or_default()
105 .trim_matches(['<', '>'])
106 .to_string(),
107 'd' => format_date(m.date),
108 '%' => "%".to_string(),
109 other => format!("%{other}"),
110 })
111}
112
113fn expand_strftime(fmt: &str, date: i64) -> String {
115 let mut out = String::new();
116 let mut rest = fmt;
117 while let Some(at) = rest.find("%{") {
118 out.push_str(&rest[..at]);
119 let Some(end) = rest[at + 2..].find('}') else {
120 break;
121 };
122 let spec = &rest[at + 2..at + 2 + end];
123 out.push_str(&strftime(spec, date));
124 rest = &rest[at + 2 + end + 1..];
125 }
126 out.push_str(rest);
127 out
128}
129
130fn strftime(spec: &str, epoch: i64) -> String {
131 match Local.timestamp_opt(epoch, 0) {
132 chrono::LocalResult::Single(dt) | chrono::LocalResult::Ambiguous(dt, _) => {
133 dt.format(spec).to_string()
134 }
135 chrono::LocalResult::None => String::new(),
136 }
137}
138
139pub const REPLY_REGEXP: &str = r"^(re)(\[[0-9]+\])*:[ \t]*";
142
143pub fn reply_regexp(spec: &str) -> Result<regex_lite::Regex, regex_lite::Error> {
147 let smart = if spec.chars().any(char::is_uppercase) {
148 spec.to_string()
149 } else {
150 format!("(?i){spec}")
151 };
152 regex_lite::Regex::new(&smart)
153}
154
155pub fn default_reply_regexp() -> regex_lite::Regex {
156 reply_regexp(REPLY_REGEXP).expect("default reply_regexp compiles")
157}
158
159pub fn reply_subject(orig: &str, re: ®ex_lite::Regex) -> String {
163 let t = orig.trim();
164 let rest = match re.find(t) {
165 Some(m) if m.start() == 0 => t[m.end()..].trim_start(),
166 _ => t,
167 };
168 format!("Re: {rest}")
169}
170
171pub fn forward_subject(fmt: &str, m: &Quoted) -> String {
173 render_quoted(fmt, m)
174}
175
176fn format_date(epoch: i64) -> String {
177 match Local.timestamp_opt(epoch, 0) {
178 chrono::LocalResult::Single(dt) | chrono::LocalResult::Ambiguous(dt, _) => {
179 dt.format("%a, %d %b %Y %H:%M").to_string()
180 }
181 chrono::LocalResult::None => "an unknown date".into(),
182 }
183}
184
185pub fn attribution(fmt: &str, m: &Quoted) -> String {
187 render_quoted(fmt, m)
188}
189
190pub fn quote(attribution: &str, indent: &str, body: &str) -> String {
193 let mut out = format!("{attribution}\n");
194 for line in body.lines() {
195 out += &format!("{indent}{line}\n");
196 }
197 out
198}
199
200pub fn forward_body(
205 from: &str,
206 date_epoch: i64,
207 subject: &str,
208 body: &str,
209 quote_with: Option<&str>,
210) -> String {
211 let body = body.trim_end();
212 let body = match quote_with {
213 Some(indent) => body
214 .lines()
215 .map(|l| format!("{indent}{l}"))
216 .collect::<Vec<_>>()
217 .join("\n"),
218 None => body.to_string(),
219 };
220 format!(
221 "----- Forwarded message from {from} -----\nDate: {}\nSubject: {subject}\n\n{body}\n----- End forwarded message -----\n",
222 format_date(date_epoch),
223 )
224}
225
226pub fn signature_text(setting: &str) -> Option<String> {
231 let setting = setting.trim();
232 if setting.is_empty() {
233 return None;
234 }
235 let text = match setting.strip_suffix('|') {
236 Some(command) => {
237 let out = std::process::Command::new("sh")
238 .arg("-c")
239 .arg(command.trim())
240 .output()
241 .ok()?;
242 String::from_utf8_lossy(&out.stdout).into_owned()
243 }
244 None => std::fs::read_to_string(expand_home(setting)).ok()?,
245 };
246 let text = text.trim_end_matches('\n');
247 (!text.is_empty()).then(|| text.to_string())
248}
249
250pub fn with_signature(body: &str, signature: &str, dashes: bool) -> String {
253 with_signature_at(body, signature, dashes, false)
254}
255
256pub fn with_signature_at(body: &str, signature: &str, dashes: bool, on_top: bool) -> String {
260 let mut sig = String::new();
261 if dashes {
262 sig += "-- \n";
263 }
264 sig += signature;
265 if !sig.ends_with('\n') {
266 sig.push('\n');
267 }
268 if on_top {
269 return format!("{sig}\n{body}");
270 }
271 let mut out = body.to_string();
272 if !out.is_empty() && !out.ends_with('\n') {
273 out.push('\n');
274 }
275 out.push('\n');
276 out += &sig;
277 out
278}
279
280pub fn make_message_id(hostname: &str) -> String {
281 format!(
282 "<{}.{}.rmut@{hostname}>",
283 Local::now().timestamp_millis(),
284 std::process::id(),
285 )
286}
287
288pub fn rfc2822_now() -> String {
289 Local::now().to_rfc2822()
290}
291
292fn header_present(head: &str, name: &str) -> bool {
293 head.lines().any(|l| {
294 l.get(..name.len())
295 .is_some_and(|k| k.eq_ignore_ascii_case(name))
296 && l.as_bytes().get(name.len()) == Some(&b':')
297 })
298}
299
300pub fn user_agent_header() -> String {
303 format!("User-Agent: rmut/{}", env!("CARGO_PKG_VERSION"))
304}
305
306pub fn finalize(draft: &str, from: &str, msg_id: &str, date: &str) -> Result<String> {
308 finalize_with(draft, from, msg_id, date, false)
309}
310
311pub fn finalize_with(
314 draft: &str,
315 from: &str,
316 msg_id: &str,
317 date: &str,
318 user_agent: bool,
319) -> Result<String> {
320 let (head, body) = draft.split_once("\n\n").unwrap_or((draft.trim_end(), ""));
321 let has_recipients = head.lines().any(|l| {
322 l.split_once(':').is_some_and(|(k, v)| {
323 ["to", "cc", "bcc"].contains(&k.trim().to_lowercase().as_str()) && !v.trim().is_empty()
324 })
325 });
326 ensure!(has_recipients, "no recipients (To/Cc/Bcc)");
327 let mut head = head.trim_end().to_string();
328 if !header_present(&head, "From") {
329 head += &format!("\nFrom: {from}");
330 }
331 if !header_present(&head, "Date") {
332 head += &format!("\nDate: {date}");
333 }
334 if !header_present(&head, "Message-ID") {
335 head += &format!("\nMessage-ID: {msg_id}");
336 }
337 if user_agent && !header_present(&head, "User-Agent") {
338 head += &format!("\n{}", user_agent_header());
339 }
340 Ok(format!("{head}\n\n{body}"))
341}
342
343pub struct Attachment {
345 pub path: PathBuf,
346 pub mime: Option<String>,
349 pub description: Option<String>,
350}
351
352fn looks_like_mime(token: &str) -> bool {
355 match token.split_once('/') {
356 Some((t, s)) if !t.is_empty() && !s.is_empty() => token
357 .chars()
358 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '.' | '+' | '-')),
359 _ => false,
360 }
361}
362
363pub fn attach_line(a: &Attachment) -> String {
366 let p = a.path.display().to_string();
367 let mut line = if p.contains(' ') {
368 format!("Attach: \"{p}\"")
369 } else {
370 format!("Attach: {p}")
371 };
372 if let Some(m) = &a.mime {
373 line += &format!(" {m}");
374 }
375 if let Some(d) = &a.description {
376 line += &format!(" {d}");
377 }
378 line
379}
380
381pub fn extract_attachments(draft: &str) -> (String, Vec<Attachment>) {
386 let (head, body) = match draft.split_once("\n\n") {
387 Some((h, b)) => (h, Some(b)),
388 None => (draft, None),
389 };
390 let mut attachments = Vec::new();
391 let mut kept = Vec::new();
392 for line in head.lines() {
393 let value = match line.split_once(':') {
394 Some((k, v)) if k.trim().eq_ignore_ascii_case("attach") => v.trim(),
395 _ => {
396 kept.push(line);
397 continue;
398 }
399 };
400 if value.is_empty() {
401 continue;
402 }
403 let (path, desc) = match value.strip_prefix('"') {
404 Some(rest) => rest.split_once('"').unwrap_or((rest, "")),
405 None => value.split_once(char::is_whitespace).unwrap_or((value, "")),
406 };
407 let mut desc = desc.trim();
408 let mut mime = None;
409 match desc.split_once(char::is_whitespace) {
410 Some((first, rest)) if looks_like_mime(first) => {
411 mime = Some(first.to_string());
412 desc = rest.trim();
413 }
414 None if looks_like_mime(desc) => {
415 mime = Some(desc.to_string());
416 desc = "";
417 }
418 _ => {}
419 }
420 attachments.push(Attachment {
421 path: expand_home(path),
422 mime,
423 description: (!desc.is_empty()).then(|| desc.to_string()),
424 });
425 }
426 let mut out = kept.join("\n");
427 if let Some(body) = body {
428 out += "\n\n";
429 out += body;
430 }
431 (out, attachments)
432}
433
434fn expand_home(path: &str) -> PathBuf {
435 if let Some(rest) = path.strip_prefix("~/")
436 && let Ok(home) = std::env::var("HOME")
437 {
438 return Path::new(&home).join(rest);
439 }
440 PathBuf::from(path)
441}
442
443pub fn content_type(path: &Path) -> &'static str {
445 let ext = path
446 .extension()
447 .and_then(|e| e.to_str())
448 .map(|e| e.to_ascii_lowercase());
449 match ext.as_deref() {
450 Some("txt" | "log" | "md" | "patch" | "diff") => "text/plain",
451 Some("html" | "htm") => "text/html",
452 Some("csv") => "text/csv",
453 Some("pdf") => "application/pdf",
454 Some("png") => "image/png",
455 Some("jpg" | "jpeg") => "image/jpeg",
456 Some("gif") => "image/gif",
457 Some("zip") => "application/zip",
458 Some("gz") => "application/gzip",
459 Some("tar") => "application/x-tar",
460 Some("json") => "application/json",
461 Some("xml") => "application/xml",
462 _ => "application/octet-stream",
463 }
464}
465
466fn b64_wrapped(bytes: &[u8]) -> String {
468 let s = crate::smtp::b64(bytes);
469 let mut out = String::with_capacity(s.len() + s.len() / 38 + 2);
470 for chunk in s.as_bytes().chunks(76) {
471 out.push_str(std::str::from_utf8(chunk).expect("base64 is ascii"));
472 out.push_str("\r\n");
473 }
474 out
475}
476
477pub fn text_entity(body: &str, flowed: bool) -> String {
483 let mut out = String::from("Content-Type: text/plain; charset=utf-8");
484 if flowed {
485 out += "; format=flowed";
486 }
487 out += "\r\nContent-Transfer-Encoding: 8bit\r\n\r\n";
488 let body = match flowed {
489 true => crate::flowed::space_stuff(body),
490 false => body.to_string(),
491 };
492 out += &String::from_utf8_lossy(&crate::pgp::crlf(body.as_bytes()));
493 out
494}
495
496pub fn flow_plain(text: &str) -> String {
501 let (head, body) = match text.split_once("\n\n") {
502 Some(pair) => pair,
503 None => return text.to_string(),
504 };
505 if header_present(head, "Content-Type") {
506 return text.to_string();
507 }
508 format!(
509 "{}\nMIME-Version: 1.0\nContent-Type: text/plain; charset=utf-8; format=flowed\n\
510 Content-Transfer-Encoding: 8bit\n\n{}",
511 head.trim_end(),
512 crate::flowed::space_stuff(body),
513 )
514}
515
516pub fn mixed_entity(
522 body: &str,
523 files: &[Attachment],
524 original: Option<&[u8]>,
525 flowed: bool,
526) -> Result<String> {
527 let mut parts: Vec<String> = Vec::new();
528 parts.push(text_entity(body, flowed));
529 for a in files {
530 let bytes =
531 std::fs::read(&a.path).with_context(|| format!("reading {}", a.path.display()))?;
532 let name = a
533 .path
534 .file_name()
535 .and_then(|n| n.to_str())
536 .unwrap_or("attachment");
537 let mut p = format!(
538 "Content-Type: {}\r\nContent-Disposition: attachment; filename=\"{name}\"\r\n",
539 a.mime.as_deref().unwrap_or_else(|| content_type(&a.path)),
540 );
541 if let Some(d) = &a.description {
542 p += &format!("Content-Description: {d}\r\n");
543 }
544 p += "Content-Transfer-Encoding: base64\r\n\r\n";
545 p += &b64_wrapped(&bytes);
546 parts.push(p);
547 }
548 if let Some(orig) = original {
549 let mut p =
550 String::from("Content-Type: message/rfc822\r\nContent-Disposition: attachment\r\n\r\n");
551 p += &String::from_utf8_lossy(&crate::pgp::crlf(orig));
552 parts.push(p);
553 }
554 let boundary = {
555 let mut n = 0usize;
556 loop {
557 let b = format!("=-rmut-mixed-{}-{n}", std::process::id());
558 if !parts.iter().any(|p| p.contains(&b)) {
559 break b;
560 }
561 n += 1;
562 }
563 };
564 let mut out = format!("Content-Type: multipart/mixed; boundary=\"{boundary}\"\r\n\r\n");
565 for p in &parts {
566 out += &format!("--{boundary}\r\n");
567 out += p;
568 if !out.ends_with("\r\n") {
569 out += "\r\n";
570 }
571 }
572 out += &format!("--{boundary}--\r\n");
573 Ok(out)
574}
575
576pub fn bounce_text(original: &[u8], from: &str, to: &str, date: &str, msg_id: &str) -> String {
579 format!(
580 "Resent-From: {from}\r\nResent-Date: {date}\r\nResent-Message-ID: {msg_id}\r\nResent-To: {to}\r\n{}",
581 String::from_utf8_lossy(original),
582 )
583}
584
585pub fn list_post_address(value: &str) -> Option<String> {
591 let value = value.trim();
592 if value.eq_ignore_ascii_case("NO") {
593 return None;
594 }
595 let start = value.to_ascii_lowercase().find("mailto:")? + "mailto:".len();
596 let rest = &value[start..];
597 let addr = rest
598 .split(['>', '?', ',', ' '])
599 .next()
600 .unwrap_or(rest)
601 .trim();
602 (!addr.is_empty()).then(|| addr.to_string())
603}
604
605pub fn followup_to(to: &str, cc: &str, me: Me, subscribed: bool, my_from: &str) -> String {
610 let mut out: Vec<String> = Vec::new();
611 let mut push = |single: &mailparse::SingleInfo| {
612 if subscribed && me.is_me(&single.addr) {
613 return;
614 }
615 let written = match &single.display_name {
616 Some(name) if !name.trim().is_empty() => format!("{name} <{}>", single.addr),
617 _ => single.addr.clone(),
618 };
619 if !out.iter().any(|a| a == &written) {
620 out.push(written);
621 }
622 };
623 for field in [to, cc] {
624 let Ok(list) = mailparse::addrparse(field) else {
625 continue;
626 };
627 for addr in list.iter() {
628 match addr {
629 mailparse::MailAddr::Single(single) => push(single),
630 mailparse::MailAddr::Group(group) => group.addrs.iter().for_each(&mut push),
631 }
632 }
633 }
634 if !subscribed
635 && let Some(from) = bare_address(my_from)
636 && !out
637 .iter()
638 .any(|a| bare_address(a).is_some_and(|b| b == from))
639 {
640 out.push(my_from.trim().to_string());
641 }
642 out.join(", ")
643}
644
645pub fn group_recipients(orig_to: &str, orig_cc: &str, to: &str, me: Me, metoo: bool) -> String {
651 let mut seen: Vec<String> = addresses(to).iter().map(|a| a.to_lowercase()).collect();
652 let mut out: Vec<String> = Vec::new();
653 let mut push = |single: &mailparse::SingleInfo| {
654 let bare = single.addr.to_lowercase();
655 if seen.contains(&bare) || (!metoo && me.is_me(&bare)) {
656 return;
657 }
658 seen.push(bare);
659 out.push(match &single.display_name {
660 Some(name) if !name.trim().is_empty() => format!("{name} <{}>", single.addr),
661 _ => single.addr.clone(),
662 });
663 };
664 for field in [orig_to, orig_cc] {
665 let Ok(list) = mailparse::addrparse(field) else {
666 continue;
667 };
668 for addr in list.iter() {
669 match addr {
670 mailparse::MailAddr::Single(single) => push(single),
671 mailparse::MailAddr::Group(group) => group.addrs.iter().for_each(&mut push),
672 }
673 }
674 }
675 out.join(", ")
676}
677
678pub fn draft_envelope(text: &str, path: &Path) -> crate::message::Envelope {
685 let (head, body) = text.split_once("\n\n").unwrap_or((text.trim_end(), ""));
686 let header = |name: &str| -> String {
687 head.lines()
688 .filter_map(|l| {
689 let (k, v) = l.split_once(':')?;
690 k.trim().eq_ignore_ascii_case(name).then(|| v.trim())
691 })
692 .collect::<Vec<_>>()
693 .join(", ")
694 };
695 let bare = |field: &str| -> Vec<String> {
696 addresses(field).iter().map(|a| a.to_lowercase()).collect()
697 };
698 let from_full = header("From");
699 crate::message::Envelope {
700 file: crate::maildir::MailFile {
701 path: path.to_path_buf(),
702 is_new: false,
703 flags: crate::maildir::Flags {
704 seen: true,
705 ..Default::default()
706 },
707 size: text.len() as u64,
708 },
709 from: crate::message::short_from(&from_full),
710 from_full,
711 subject: header("Subject"),
712 date: Local::now().timestamp(),
713 msg_id: None,
714 references: Vec::new(),
715 tagged: false,
716 to: bare(&header("To")),
717 cc: [header("Cc"), header("Bcc")]
718 .iter()
719 .flat_map(|f| bare(f))
720 .collect(),
721 lines: Some(body.lines().count()),
722 list: None,
723 label: None,
724 broken: false,
725 }
726}
727
728pub fn apply_my_hdr(text: &str, my_hdr: &[String]) -> String {
735 if my_hdr.is_empty() {
736 return text.to_string();
737 }
738 let (head, body) = match text.split_once("\n\n") {
739 Some((head, body)) => (head, body),
740 None => (text.trim_end(), ""),
741 };
742 let mut lines: Vec<String> = head.lines().map(String::from).collect();
743 for entry in my_hdr {
744 let Some((name, value)) = entry.split_once(':') else {
745 continue;
746 };
747 let (name, value) = (name.trim(), value.trim());
748 if name.is_empty() {
749 continue;
750 }
751 let at = lines.iter().position(|l| {
752 l.get(..name.len())
753 .is_some_and(|k| k.eq_ignore_ascii_case(name))
754 && l.as_bytes().get(name.len()) == Some(&b':')
755 });
756 let addressy = ["to", "cc", "bcc"].contains(&name.to_lowercase().as_str());
757 match at {
758 Some(i) if addressy => {
759 let old = lines[i]
760 .split_once(':')
761 .map(|(_, v)| v.trim().to_string())
762 .unwrap_or_default();
763 lines[i] = if old.is_empty() {
764 format!("{name}: {value}")
765 } else {
766 format!("{name}: {old}, {value}")
767 };
768 }
769 Some(i) => lines[i] = format!("{name}: {value}"),
770 None => lines.push(format!("{name}: {value}")),
771 }
772 }
773 format!("{}\n\n{body}", lines.join("\n"))
774}
775
776pub fn bare_address(field: &str) -> Option<String> {
777 match mailparse::addrparse(field)
778 .ok()?
779 .into_inner()
780 .into_iter()
781 .next()?
782 {
783 mailparse::MailAddr::Single(single) => Some(single.addr),
784 mailparse::MailAddr::Group(group) => group.addrs.first().map(|a| a.addr.clone()),
785 }
786}
787
788pub fn from_address(text: &str) -> Option<String> {
790 use mailparse::MailHeaderMap;
791 let mail = mailparse::parse_mail(text.as_bytes()).ok()?;
792 bare_address(&mail.get_headers().get_first_value("From")?)
793}
794
795fn field_addresses(value: &str, out: &mut Vec<String>) {
796 let Ok(list) = mailparse::addrparse(value) else {
797 return;
798 };
799 for addr in list.iter() {
800 match addr {
801 mailparse::MailAddr::Single(single) => out.push(single.addr.clone()),
802 mailparse::MailAddr::Group(group) => {
803 out.extend(group.addrs.iter().map(|a| a.addr.clone()));
804 }
805 }
806 }
807}
808
809pub fn addresses(field: &str) -> Vec<String> {
811 let mut out = Vec::new();
812 field_addresses(field, &mut out);
813 out
814}
815
816pub fn reverse_from(orig_to: &str, orig_cc: &str, me: Me, realname: bool) -> Option<String> {
820 let mine = |single: &mailparse::SingleInfo| -> Option<String> {
821 if !me.is_me(&single.addr) {
822 return None;
823 }
824 Some(match &single.display_name {
825 Some(name) if realname && !name.trim().is_empty() => {
828 format!("{name} <{}>", single.addr)
829 }
830 _ => single.addr.clone(),
831 })
832 };
833 for field in [orig_to, orig_cc] {
834 let Ok(list) = mailparse::addrparse(field) else {
835 continue;
836 };
837 for addr in list.iter() {
838 match addr {
839 mailparse::MailAddr::Single(single) => {
840 if let Some(from) = mine(single) {
841 return Some(from);
842 }
843 }
844 mailparse::MailAddr::Group(group) => {
845 if let Some(from) = group.addrs.iter().find_map(&mine) {
846 return Some(from);
847 }
848 }
849 }
850 }
851 }
852 None
853}
854
855pub fn smtp_envelope(text: &str) -> Result<(Vec<String>, String)> {
858 let mail = mailparse::parse_mail(text.as_bytes())?;
859 let mut rcpts = Vec::new();
860 for header in &mail.headers {
861 let key = header.get_key();
862 if ["to", "cc", "bcc"].contains(&key.to_lowercase().as_str()) {
863 field_addresses(&header.get_value(), &mut rcpts);
864 }
865 }
866 rcpts.dedup();
867 let (head, body) = text.split_once("\n\n").unwrap_or((text.trim_end(), ""));
868 let mut out = String::new();
869 let mut skipping = false;
870 for line in head.lines() {
871 if line.starts_with(' ') || line.starts_with('\t') {
872 if skipping {
874 continue;
875 }
876 } else {
877 skipping = line
878 .split_once(':')
879 .is_some_and(|(k, _)| k.trim().eq_ignore_ascii_case("bcc"));
880 }
881 if !skipping {
882 out.push_str(line);
883 out.push('\n');
884 }
885 }
886 out.push('\n');
887 out.push_str(body);
888 Ok((rcpts, out))
889}
890
891#[cfg(test)]
892mod tests {
893 use super::*;
894
895 #[test]
896 fn a_forward_can_come_in_quoted() {
897 let plain = forward_body("Ann <ann@x>", 0, "hi", "one\ntwo\n", None);
898 assert!(plain.contains("\none\ntwo\n"), "{plain}");
899 let quoted = forward_body("Ann <ann@x>", 0, "hi", "one\ntwo\n", Some("> "));
900 assert!(quoted.contains("\n> one\n> two\n"), "{quoted}");
901 assert!(quoted.starts_with("----- Forwarded message from Ann <ann@x> -----"));
903 assert!(quoted.ends_with("----- End forwarded message -----\n"));
904 }
905
906 #[test]
907 fn the_signature_sits_under_a_dashes_line() {
908 let body = with_signature("hello\n", "Ann\nx.example", true);
909 assert_eq!(body, "hello\n\n-- \nAnn\nx.example\n");
910 assert_eq!(with_signature("hello\n", "Ann", false), "hello\n\nAnn\n");
912 assert_eq!(with_signature("", "Ann", true), "\n-- \nAnn\n");
914 }
915
916 #[test]
917 fn a_signature_comes_from_a_file_or_a_command() {
918 let dir = std::env::temp_dir().join(format!("rmut-sig-{}", std::process::id()));
919 std::fs::create_dir_all(&dir).unwrap();
920 let file = dir.join("signature");
921 std::fs::write(&file, "Ann\n\n\n").unwrap();
922 assert_eq!(
924 signature_text(file.to_str().unwrap()).as_deref(),
925 Some("Ann")
926 );
927 assert_eq!(signature_text("echo hello|").as_deref(), Some("hello"));
928 assert_eq!(signature_text(dir.join("gone").to_str().unwrap()), None);
930 assert_eq!(signature_text(" "), None);
931 assert_eq!(signature_text("true|"), None);
932 std::fs::remove_dir_all(&dir).unwrap();
933 }
934
935 #[test]
936 fn list_post_addresses() {
937 assert_eq!(
938 list_post_address("<mailto:dev@example.com>").as_deref(),
939 Some("dev@example.com")
940 );
941 assert_eq!(
942 list_post_address("<mailto:dev@example.com?subject=help>").as_deref(),
943 Some("dev@example.com")
944 );
945 assert_eq!(
946 list_post_address("NOTE: <mailto:dev@example.com>, <http://x/post>").as_deref(),
947 Some("dev@example.com")
948 );
949 assert_eq!(list_post_address("NO"), None);
951 assert_eq!(list_post_address("<http://example.com/post>"), None);
952 }
953
954 #[test]
955 fn followup_to_drops_me_only_when_subscribed() {
956 let addrs = vec!["jarda@example.com".to_string()];
957 let me = Me::addresses(&addrs);
958 let subscribed = followup_to(
959 "dev@example.com, Jarda <jarda@example.com>",
960 "",
961 me,
962 true,
963 "Jarda <jarda@example.com>",
964 );
965 assert_eq!(subscribed, "dev@example.com");
966 let unsubscribed = followup_to(
967 "dev@example.com",
968 "petr@example.com",
969 me,
970 false,
971 "Jarda <jarda@example.com>",
972 );
973 assert_eq!(
974 unsubscribed,
975 "dev@example.com, petr@example.com, Jarda <jarda@example.com>"
976 );
977 let once = followup_to(
979 "dev@example.com, jarda@example.com",
980 "",
981 me,
982 false,
983 "Jarda <jarda@example.com>",
984 );
985 assert_eq!(once, "dev@example.com, jarda@example.com");
986 }
987
988 #[test]
989 fn group_reply_drops_me_and_the_sender() {
990 let addrs = vec!["jarda@example.com".to_string()];
991 let alternates = vec![crate::pattern::Matcher::new("^jp@old\\.example\\.com$")];
992 let me = Me::new(&addrs, &alternates);
993 let cc = group_recipients(
994 "Team <team@example.com>, Jarda <jarda@example.com>, jp@old.example.com",
995 "boss@example.com, team@example.com",
996 "Petr <petr@example.com>",
997 me,
998 false,
999 );
1000 assert_eq!(cc, "Team <team@example.com>, boss@example.com");
1003 let cc = group_recipients(
1005 "team@example.com, jarda@example.com",
1006 "",
1007 "petr@example.com",
1008 me,
1009 true,
1010 );
1011 assert_eq!(cc, "team@example.com, jarda@example.com");
1012 }
1013
1014 #[test]
1015 fn text_flowed_declares_and_stuffs_the_body() {
1016 let entity = text_entity("plain\n>looks quoted\n", true);
1017 assert!(
1018 entity.starts_with("Content-Type: text/plain; charset=utf-8; format=flowed\r\n"),
1019 "{entity}"
1020 );
1021 assert!(
1022 entity.ends_with("plain\r\n >looks quoted\r\n"),
1023 "{entity:?}"
1024 );
1025 let plain = text_entity("plain\n>looks quoted\n", false);
1027 assert!(plain.starts_with("Content-Type: text/plain; charset=utf-8\r\n"));
1028 assert!(plain.ends_with("plain\r\n>looks quoted\r\n"), "{plain:?}");
1029 }
1030
1031 #[test]
1032 fn flow_plain_declares_an_unwrapped_draft() {
1033 let draft = "From: a@x\nTo: b@x\nSubject: s\n\n>quoted line\n";
1034 let out = flow_plain(draft);
1035 assert!(out.contains("Content-Type: text/plain; charset=utf-8; format=flowed\n"));
1036 assert!(out.ends_with("\n\n >quoted line\n"), "{out:?}");
1037 let typed = "From: a@x\nContent-Type: text/x-diff\n\nbody\n";
1039 assert_eq!(flow_plain(typed), typed);
1040 }
1041
1042 #[test]
1043 fn draft_envelope_reads_the_header_block() {
1044 let draft = "From: Jane Doe <jane@example.com>\n\
1045 To: Bob <BOB@work.example.com>, team@x\n\
1046 Cc: boss@x\n\
1047 Bcc: archive@x\n\
1048 Subject: quarterly\n\n\
1049 two\nlines\n";
1050 let env = draft_envelope(draft, std::path::Path::new("/tmp/draft"));
1051 assert_eq!(env.subject, "quarterly");
1052 assert_eq!(env.from_full, "Jane Doe <jane@example.com>");
1053 assert_eq!(env.to, ["bob@work.example.com", "team@x"]);
1054 assert_eq!(env.cc, ["boss@x", "archive@x"]);
1056 assert_eq!(env.lines, Some(2));
1057 let hit = |p: &str| {
1058 crate::pattern::matches_in(
1059 &crate::pattern::parse(p).unwrap(),
1060 &env,
1061 crate::pattern::Scope::default(),
1062 None,
1063 )
1064 };
1065 assert!(hit("~t @work\\.example\\.com"));
1066 assert!(hit("~c archive@"));
1067 assert!(hit("~A"));
1068 assert!(!hit("~t nobody@"));
1069 }
1070
1071 #[test]
1072 fn my_hdr_merges_into_the_draft_head() {
1073 let draft = "From: jane@example.com\nTo: bob@x\nSubject: s\n\nbody\n";
1074 let merged = apply_my_hdr(
1075 draft,
1076 &[
1077 "Organization: Acme".to_string(),
1078 "From: Jane <jane@work.example.com>".into(),
1079 "Bcc: jane@example.com".into(),
1080 "To: archive@x".into(),
1081 "bogus".into(),
1082 ],
1083 );
1084 assert_eq!(
1085 merged,
1086 "From: Jane <jane@work.example.com>\n\
1087 To: bob@x, archive@x\n\
1088 Subject: s\n\
1089 Organization: Acme\n\
1090 Bcc: jane@example.com\n\
1091 \nbody\n"
1092 );
1093 assert_eq!(apply_my_hdr(draft, &[]), draft);
1095 }
1096
1097 fn quoted() -> Quoted<'static> {
1099 Quoted {
1100 from: "Jane Doe <jane@example.com>",
1101 subject: "Lunch",
1102 message_id: Some("<m1@example.com>"),
1103 date: 1_710_151_200,
1105 }
1106 }
1107
1108 #[test]
1109 fn subjects_do_not_stack_prefixes() {
1110 let re = default_reply_regexp();
1111 assert_eq!(reply_subject("Lunch", &re), "Re: Lunch");
1112 assert_eq!(reply_subject("RE: Lunch", &re), "Re: Lunch");
1113 assert_eq!(reply_subject("Re[2]: Lunch", &re), "Re: Lunch");
1114 let aw = reply_regexp("^(re|aw|sv):[ \t]*").unwrap();
1117 assert_eq!(reply_subject("AW: Lunch", &aw), "Re: Lunch");
1118 let exact = reply_regexp("^(Re):[ \t]*").unwrap();
1119 assert_eq!(reply_subject("RE: Lunch", &exact), "Re: RE: Lunch");
1120 assert_eq!(
1121 forward_subject(DEFAULT_FORWARD_FORMAT, "ed()),
1122 "[jane@example.com: Lunch]"
1123 );
1124 }
1125
1126 #[test]
1127 fn quote_prefixes_every_line_with_the_indent_string() {
1128 assert_eq!(
1129 quote("On X, Y wrote:", DEFAULT_INDENT, "a\nb"),
1130 "On X, Y wrote:\n> a\n> b\n"
1131 );
1132 assert_eq!(quote("head", "| ", "a"), "head\n| a\n");
1133 }
1134
1135 #[test]
1136 fn an_attribution_says_who_and_when() {
1137 let m = quoted();
1138 let line = attribution(DEFAULT_ATTRIBUTION, &m);
1140 assert!(line.starts_with("On "), "{line}");
1141 assert!(line.ends_with(", Jane Doe wrote:"), "{line}");
1142 assert_eq!(render_quoted("%a", &m), "jane@example.com");
1144 assert_eq!(render_quoted("%n", &m), "Jane Doe");
1145 assert_eq!(render_quoted("%f", &m), "Jane Doe <jane@example.com>");
1146 assert_eq!(render_quoted("%s", &m), "Lunch");
1147 assert_eq!(render_quoted("%i", &m), "m1@example.com");
1148 assert_eq!(render_quoted("100%%", &m), "100%");
1149 assert_eq!(render_quoted("%q", &m), "%q");
1150 assert_eq!(render_quoted("%{%Y}", &m), "2024");
1152 assert_eq!(render_quoted("[%{%Y}] %s", &m), "[2024] Lunch");
1153 }
1154
1155 #[test]
1156 fn a_name_falls_back_to_the_address() {
1157 let m = Quoted {
1158 from: "bare@example.com",
1159 subject: "x",
1160 message_id: None,
1161 date: 0,
1162 };
1163 assert_eq!(render_quoted("%n", &m), "bare@example.com");
1164 assert_eq!(render_quoted("%i", &m), "");
1165 }
1166
1167 #[test]
1168 fn draft_text_skips_empty_optional_headers() {
1169 let text = draft_text(
1170 &DraftHeaders {
1171 from: None,
1172 to: "a@x".into(),
1173 cc: Some("".into()),
1174 subject: "s".into(),
1175 in_reply_to: None,
1176 references: None,
1177 },
1178 "hi",
1179 );
1180 assert_eq!(text, "To: a@x\nSubject: s\n\nhi\n");
1181 let text = draft_text(
1182 &DraftHeaders {
1183 from: Some("Jane Work <jane@work.example.com>".into()),
1184 to: "a@x".into(),
1185 cc: None,
1186 subject: "s".into(),
1187 in_reply_to: None,
1188 references: None,
1189 },
1190 "hi",
1191 );
1192 assert!(text.starts_with("From: Jane Work <jane@work.example.com>\nTo: a@x\n"));
1193 }
1194
1195 #[test]
1196 fn reverse_from_finds_my_address_as_it_appeared() {
1197 let addrs = vec!["jane@example.com".to_string(), "old@example.com".into()];
1198 let me = Me::addresses(&addrs);
1199 assert_eq!(
1201 reverse_from("Boss Me <Jane@example.com>, bob@y", "", me, true).as_deref(),
1202 Some("Boss Me <Jane@example.com>")
1203 );
1204 assert_eq!(
1206 reverse_from("Boss Me <Jane@example.com>, bob@y", "", me, false).as_deref(),
1207 Some("Jane@example.com")
1208 );
1209 assert_eq!(
1211 reverse_from("bob@y", "old@example.com", me, true).as_deref(),
1212 Some("old@example.com")
1213 );
1214 assert_eq!(reverse_from("bob@y, eve@z", "", me, true), None);
1215 assert_eq!(reverse_from("", "", me, true), None);
1216 }
1217
1218 #[test]
1219 fn finalize_adds_missing_headers_once() {
1220 let draft = "To: a@x\nSubject: s\n\nbody\n";
1221 let out = finalize(draft, "me@host", "<id@host>", "DATE").unwrap();
1222 assert!(out.contains("From: me@host"));
1223 assert!(out.contains("Message-ID: <id@host>"));
1224 assert!(out.contains("Date: DATE"));
1225 assert!(out.ends_with("\n\nbody\n"));
1226 let draft2 = "To: a@x\nFrom: custom@x\n\nbody\n";
1228 let out2 = finalize(draft2, "me@host", "<i>", "D").unwrap();
1229 assert!(out2.contains("From: custom@x"));
1230 assert!(!out2.contains("me@host"));
1231 }
1232
1233 #[test]
1234 fn user_agent_and_signature_placement() {
1235 let draft = "To: a@b\n\nhi";
1236 let plain = finalize_with(draft, "me@x", "<id@h>", "Mon", false).unwrap();
1237 assert!(!plain.contains("User-Agent"));
1238 let ua = finalize_with(draft, "me@x", "<id@h>", "Mon", true).unwrap();
1239 assert!(ua.contains(&user_agent_header()), "{ua}");
1240 let own = finalize_with(
1242 "To: a@b\nUser-Agent: mine\n\nhi",
1243 "me@x",
1244 "<id@h>",
1245 "Mon",
1246 true,
1247 )
1248 .unwrap();
1249 assert_eq!(own.matches("User-Agent").count(), 1, "{own}");
1250 let below = with_signature_at("the reply", "Jane", true, false);
1252 assert!(below.trim_end().ends_with("Jane"), "{below}");
1253 let above = with_signature_at("the reply", "Jane", true, true);
1254 assert!(above.starts_with("-- \nJane\n"), "{above}");
1255 assert!(above.trim_end().ends_with("the reply"), "{above}");
1256 }
1257
1258 #[test]
1259 fn finalize_rejects_missing_recipients() {
1260 assert!(finalize("Subject: s\n\nbody", "f", "<i>", "d").is_err());
1261 assert!(finalize("To: \nSubject: s\n\nbody", "f", "<i>", "d").is_err());
1262 assert!(finalize("Bcc: a@x\n\nbody", "f", "<i>", "d").is_ok());
1263 }
1264
1265 #[test]
1266 fn bare_address_drops_display_name() {
1267 assert_eq!(
1268 bare_address("Jane <jane@x.org>").as_deref(),
1269 Some("jane@x.org")
1270 );
1271 assert_eq!(bare_address("jane@x.org").as_deref(), Some("jane@x.org"));
1272 assert_eq!(bare_address(""), None);
1273 }
1274
1275 #[test]
1276 fn smtp_envelope_collects_rcpts_and_strips_bcc() {
1277 let text =
1278 "To: Alice <a@x>, b@y\nCc: c@z\nBcc: hidden@q,\n also-hidden@q\nSubject: s\n\nbody\n";
1279 let (rcpts, out) = smtp_envelope(text).unwrap();
1280 assert_eq!(
1281 rcpts,
1282 vec!["a@x", "b@y", "c@z", "hidden@q", "also-hidden@q"]
1283 );
1284 assert!(!out.to_lowercase().contains("bcc"));
1285 assert!(!out.contains("hidden@q"));
1286 assert!(out.contains("To: Alice <a@x>, b@y\n"));
1287 assert!(out.ends_with("\n\nbody\n"));
1288 }
1289
1290 #[test]
1291 fn extract_attachments_takes_the_pseudo_headers_out() {
1292 let draft = "To: a@x\nAttach: /tmp/report.pdf the Q2 numbers\n\
1293 attach: \"/tmp/two words.png\"\nAttach:\nSubject: s\n\nbody\n";
1294 let (out, files) = extract_attachments(draft);
1295 assert_eq!(out, "To: a@x\nSubject: s\n\nbody\n");
1296 assert_eq!(files.len(), 2);
1297 assert_eq!(files[0].path, PathBuf::from("/tmp/report.pdf"));
1298 assert_eq!(files[0].description.as_deref(), Some("the Q2 numbers"));
1299 assert_eq!(files[1].path, PathBuf::from("/tmp/two words.png"));
1300 assert_eq!(files[1].description, None);
1301 }
1302
1303 #[test]
1304 fn attach_lines_carry_a_type_override() {
1305 let draft = "Attach: /tmp/x.bin application/x-custom raw dump\n\
1306 Attach: /tmp/y.txt see notes\n\n";
1307 let (_, files) = extract_attachments(draft);
1308 assert_eq!(files[0].mime.as_deref(), Some("application/x-custom"));
1309 assert_eq!(files[0].description.as_deref(), Some("raw dump"));
1310 assert_eq!(files[1].mime, None);
1312 assert_eq!(files[1].description.as_deref(), Some("see notes"));
1313 let line = attach_line(&files[0]);
1315 assert_eq!(line, "Attach: /tmp/x.bin application/x-custom raw dump");
1316 let (_, roundtrip) = extract_attachments(&format!("{line}\n\n"));
1317 assert_eq!(roundtrip[0].mime.as_deref(), Some("application/x-custom"));
1318 let spaced = Attachment {
1320 path: PathBuf::from("/tmp/two words.png"),
1321 mime: Some("image/png".into()),
1322 description: None,
1323 };
1324 let (_, files) = extract_attachments(&format!("{}\n\n", attach_line(&spaced)));
1325 assert_eq!(files[0].path, PathBuf::from("/tmp/two words.png"));
1326 assert_eq!(files[0].mime.as_deref(), Some("image/png"));
1327 }
1328
1329 #[test]
1330 fn extract_attachments_leaves_plain_drafts_alone() {
1331 let draft = "To: a@x\nSubject: s\n\nAttach: not a header, body text\n";
1332 let (out, files) = extract_attachments(draft);
1333 assert_eq!(out, draft);
1334 assert!(files.is_empty());
1335 }
1336
1337 #[test]
1338 fn mixed_entity_encodes_files_and_original() {
1339 use mailparse::MailHeaderMap;
1340 let dir = std::env::temp_dir().join(format!("rmut-attach-test-{}", std::process::id()));
1341 std::fs::create_dir_all(&dir).unwrap();
1342 let blob: Vec<u8> = (0..=255u8).collect();
1343 std::fs::write(dir.join("blob.bin"), &blob).unwrap();
1344 let files = [Attachment {
1345 path: dir.join("blob.bin"),
1346 mime: None,
1347 description: Some("raw bytes".into()),
1348 }];
1349 let orig = b"From: jane@x\r\nSubject: hi\r\n\r\noriginal body\r\n";
1350 let entity = mixed_entity("see attached", &files, Some(orig), false).unwrap();
1351 let mail = mailparse::parse_mail(entity.as_bytes()).unwrap();
1352 assert_eq!(mail.ctype.mimetype, "multipart/mixed");
1353 assert_eq!(mail.subparts.len(), 3);
1354 assert_eq!(mail.subparts[0].get_body().unwrap().trim(), "see attached");
1355 let file = &mail.subparts[1];
1356 assert_eq!(file.ctype.mimetype, "application/octet-stream");
1357 assert_eq!(file.get_body_raw().unwrap(), blob);
1358 let disp = file.get_headers().get_first_value("Content-Disposition");
1359 assert!(disp.unwrap().contains("filename=\"blob.bin\""));
1360 assert_eq!(
1361 file.get_headers()
1362 .get_first_value("Content-Description")
1363 .as_deref(),
1364 Some("raw bytes")
1365 );
1366 assert_eq!(mail.subparts[2].ctype.mimetype, "message/rfc822");
1367 assert!(
1368 mail.subparts[2]
1369 .get_body()
1370 .unwrap()
1371 .contains("original body")
1372 );
1373 std::fs::remove_dir_all(&dir).unwrap();
1374 }
1375
1376 #[test]
1377 fn mixed_entity_reports_a_missing_file() {
1378 let files = [Attachment {
1379 path: PathBuf::from("/nonexistent/nope.pdf"),
1380 mime: None,
1381 description: None,
1382 }];
1383 let err = mixed_entity("hi", &files, None, false).unwrap_err();
1384 assert!(err.to_string().contains("/nonexistent/nope.pdf"));
1385 }
1386
1387 #[test]
1388 fn bounce_text_prepends_resent_headers() {
1389 let orig = b"From: jane@x\nSubject: hi\n\nbody\n";
1390 let out = bounce_text(orig, "Me <me@x>", "bob@y", "DATE", "<id@x>");
1391 assert!(out.starts_with("Resent-From: Me <me@x>\r\n"));
1392 assert!(out.contains("Resent-Date: DATE\r\n"));
1393 assert!(out.contains("Resent-To: bob@y\r\n"));
1394 assert!(out.ends_with("From: jane@x\nSubject: hi\n\nbody\n"));
1395 }
1396
1397 #[test]
1398 fn smtp_envelope_without_bcc_is_unchanged() {
1399 let text = "To: a@x\nSubject: s\n\nbody\n";
1400 let (rcpts, out) = smtp_envelope(text).unwrap();
1401 assert_eq!(rcpts, vec!["a@x"]);
1402 assert_eq!(out, text);
1403 }
1404}