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 pub name: Option<String>,
353 pub inline: bool,
356 pub unlink: bool,
359}
360
361impl Attachment {
362 pub fn of(path: PathBuf) -> Attachment {
364 Attachment {
365 path,
366 mime: None,
367 description: None,
368 name: None,
369 inline: false,
370 unlink: false,
371 }
372 }
373
374 pub fn send_name(&self) -> &str {
376 self.name
377 .as_deref()
378 .filter(|n| !n.is_empty())
379 .unwrap_or_else(|| {
380 self.path
381 .file_name()
382 .and_then(|n| n.to_str())
383 .unwrap_or("attachment")
384 })
385 }
386}
387
388fn looks_like_mime(token: &str) -> bool {
391 match token.split_once('/') {
392 Some((t, s)) if !t.is_empty() && !s.is_empty() => token
393 .chars()
394 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '.' | '+' | '-')),
395 _ => false,
396 }
397}
398
399pub fn attach_line(a: &Attachment) -> String {
402 let p = a.path.display().to_string();
403 let mut line = if p.contains(' ') {
404 format!("Attach: \"{p}\"")
405 } else {
406 format!("Attach: {p}")
407 };
408 if let Some(m) = &a.mime {
409 line += &format!(" {m}");
410 }
411 if let Some(n) = a.name.as_deref().filter(|n| !n.is_empty()) {
414 line += &format!(" @name=\"{}\"", n.replace('"', ""));
415 }
416 if a.inline {
417 line += " @inline";
418 }
419 if a.unlink {
420 line += " @unlink";
421 }
422 if let Some(d) = &a.description {
423 line += &format!(" {d}");
424 }
425 line
426}
427
428pub fn extract_attachments(draft: &str) -> (String, Vec<Attachment>) {
433 let (head, body) = match draft.split_once("\n\n") {
434 Some((h, b)) => (h, Some(b)),
435 None => (draft, None),
436 };
437 let mut attachments = Vec::new();
438 let mut kept = Vec::new();
439 for line in head.lines() {
440 let value = match line.split_once(':') {
441 Some((k, v)) if k.trim().eq_ignore_ascii_case("attach") => v.trim(),
442 _ => {
443 kept.push(line);
444 continue;
445 }
446 };
447 if value.is_empty() {
448 continue;
449 }
450 let (path, desc) = match value.strip_prefix('"') {
451 Some(rest) => rest.split_once('"').unwrap_or((rest, "")),
452 None => value.split_once(char::is_whitespace).unwrap_or((value, "")),
453 };
454 let mut desc = desc.trim();
455 let mut mime = None;
456 match desc.split_once(char::is_whitespace) {
457 Some((first, rest)) if looks_like_mime(first) => {
458 mime = Some(first.to_string());
459 desc = rest.trim();
460 }
461 None if looks_like_mime(desc) => {
462 mime = Some(desc.to_string());
463 desc = "";
464 }
465 _ => {}
466 }
467 let mut a = Attachment::of(expand_home(path));
469 a.mime = mime;
470 while let Some(rest) = desc.strip_prefix('@') {
471 if let Some(rest) = rest.strip_prefix("inline") {
472 a.inline = true;
473 desc = rest.trim_start();
474 } else if let Some(rest) = rest.strip_prefix("unlink") {
475 a.unlink = true;
476 desc = rest.trim_start();
477 } else if let Some(rest) = rest.strip_prefix("name=") {
478 let (name, rest) = match rest.strip_prefix('"') {
479 Some(q) => q.split_once('"').unwrap_or((q, "")),
480 None => rest.split_once(char::is_whitespace).unwrap_or((rest, "")),
481 };
482 a.name = (!name.is_empty()).then(|| name.to_string());
483 desc = rest.trim_start();
484 } else {
485 break; }
487 }
488 a.description = (!desc.is_empty()).then(|| desc.to_string());
489 attachments.push(a);
490 }
491 let mut out = kept.join("\n");
492 if let Some(body) = body {
493 out += "\n\n";
494 out += body;
495 }
496 (out, attachments)
497}
498
499fn expand_home(path: &str) -> PathBuf {
500 if let Some(rest) = path.strip_prefix("~/")
501 && let Ok(home) = std::env::var("HOME")
502 {
503 return Path::new(&home).join(rest);
504 }
505 PathBuf::from(path)
506}
507
508pub fn content_type(path: &Path) -> &'static str {
510 let ext = path
511 .extension()
512 .and_then(|e| e.to_str())
513 .map(|e| e.to_ascii_lowercase());
514 match ext.as_deref() {
515 Some("txt" | "log" | "md" | "patch" | "diff") => "text/plain",
516 Some("html" | "htm") => "text/html",
517 Some("csv") => "text/csv",
518 Some("pdf") => "application/pdf",
519 Some("png") => "image/png",
520 Some("jpg" | "jpeg") => "image/jpeg",
521 Some("gif") => "image/gif",
522 Some("zip") => "application/zip",
523 Some("gz") => "application/gzip",
524 Some("tar") => "application/x-tar",
525 Some("json") => "application/json",
526 Some("xml") => "application/xml",
527 _ => "application/octet-stream",
528 }
529}
530
531fn b64_wrapped(bytes: &[u8]) -> String {
533 let s = crate::smtp::b64(bytes);
534 let mut out = String::with_capacity(s.len() + s.len() / 38 + 2);
535 for chunk in s.as_bytes().chunks(76) {
536 out.push_str(std::str::from_utf8(chunk).expect("base64 is ascii"));
537 out.push_str("\r\n");
538 }
539 out
540}
541
542pub fn text_entity(body: &str, flowed: bool) -> String {
548 let mut out = String::from("Content-Type: text/plain; charset=utf-8");
549 if flowed {
550 out += "; format=flowed";
551 }
552 out += "\r\nContent-Transfer-Encoding: 8bit\r\n\r\n";
553 let body = match flowed {
554 true => crate::flowed::space_stuff(body),
555 false => body.to_string(),
556 };
557 out += &String::from_utf8_lossy(&crate::pgp::crlf(body.as_bytes()));
558 out
559}
560
561pub fn body_entity(body: &str, flowed: bool, markdown: bool) -> String {
567 let plain = text_entity(body, flowed);
568 if !markdown {
569 return plain;
570 }
571 let html = format!(
572 "Content-Type: text/html; charset=utf-8\r\n\
573 Content-Transfer-Encoding: quoted-printable\r\n\r\n{}",
574 quoted_printable(&crate::markdown::to_html(body))
575 );
576 let boundary = {
577 let mut n = 0usize;
578 loop {
579 let b = format!("=-rmut-alt-{}-{n}", std::process::id());
580 if !plain.contains(&b) && !html.contains(&b) {
581 break b;
582 }
583 n += 1;
584 }
585 };
586 let mut out = format!("Content-Type: multipart/alternative; boundary=\"{boundary}\"\r\n\r\n");
587 for part in [plain, html] {
588 out += &format!("--{boundary}\r\n{part}");
589 if !out.ends_with("\r\n") {
590 out += "\r\n";
591 }
592 }
593 out += &format!("--{boundary}--\r\n");
594 out
595}
596
597pub fn quoted_printable(text: &str) -> String {
600 let mut out = String::new();
601 for (i, line) in text.split('\n').enumerate() {
602 if i > 0 {
603 out += "\r\n";
604 }
605 let line = line.strip_suffix('\r').unwrap_or(line);
606 let bytes = line.as_bytes();
607 let mut width = 0;
608 for (j, &b) in bytes.iter().enumerate() {
609 let last = j + 1 == bytes.len();
610 let piece = match b {
611 b'=' => "=3D".to_string(),
612 b' ' | b'\t' if last => format!("={b:02X}"),
613 b' ' | b'\t' | 33..=126 => (b as char).to_string(),
614 _ => format!("={b:02X}"),
615 };
616 if width + piece.len() > 75 {
618 out += "=\r\n";
619 width = 0;
620 }
621 width += piece.len();
622 out += &piece;
623 }
624 }
625 out
626}
627
628pub const MARKDOWN_HEADER: &str = "X-Rmut-Markdown";
632
633pub fn take_markdown(draft: &str) -> (String, Option<bool>) {
636 let (head, body) = match draft.split_once("\n\n") {
637 Some((h, b)) => (h, Some(b)),
638 None => (draft, None),
639 };
640 let mut said = None;
641 let kept: Vec<&str> = head
642 .lines()
643 .filter(|line| match line.split_once(':') {
644 Some((k, v)) if k.trim().eq_ignore_ascii_case(MARKDOWN_HEADER) => {
645 said = Some(matches!(
646 v.trim().to_lowercase().as_str(),
647 "yes" | "true" | "on"
648 ));
649 false
650 }
651 _ => true,
652 })
653 .collect();
654 let head = kept.join("\n");
655 let text = match body {
656 Some(body) => format!("{head}\n\n{body}"),
657 None => head,
658 };
659 (text, said)
660}
661
662pub fn flow_plain(text: &str) -> String {
667 let (head, body) = match text.split_once("\n\n") {
668 Some(pair) => pair,
669 None => return text.to_string(),
670 };
671 if header_present(head, "Content-Type") {
672 return text.to_string();
673 }
674 format!(
675 "{}\nMIME-Version: 1.0\nContent-Type: text/plain; charset=utf-8; format=flowed\n\
676 Content-Transfer-Encoding: 8bit\n\n{}",
677 head.trim_end(),
678 crate::flowed::space_stuff(body),
679 )
680}
681
682fn filename_param(name: &str) -> String {
688 let name = one_line_value(name);
689 if name.is_ascii() && !name.contains(['"', '\\']) {
690 return format!("filename=\"{name}\"");
691 }
692 let mut out = String::from("filename*=utf-8''");
693 for b in name.bytes() {
694 if b.is_ascii_alphanumeric() || b"!#$&+-.^_`|~".contains(&b) {
696 out.push(b as char);
697 } else {
698 out += &format!("%{b:02X}");
699 }
700 }
701 out
702}
703
704fn one_line_value(value: &str) -> String {
707 value
708 .chars()
709 .map(|c| if c.is_control() { ' ' } else { c })
710 .collect()
711}
712
713pub fn mixed_entity(
719 body: &str,
720 files: &[Attachment],
721 original: Option<&[u8]>,
722 flowed: bool,
723 markdown: bool,
724) -> Result<String> {
725 let mut parts: Vec<String> = Vec::new();
726 parts.push(body_entity(body, flowed, markdown));
727 for a in files {
728 let bytes =
729 std::fs::read(&a.path).with_context(|| format!("reading {}", a.path.display()))?;
730 let mime = a.mime.as_deref().unwrap_or_else(|| content_type(&a.path));
731 let disposition = if a.inline { "inline" } else { "attachment" };
732 if mime.eq_ignore_ascii_case("message/rfc822") {
735 let mut p =
736 format!("Content-Type: message/rfc822\r\nContent-Disposition: {disposition}\r\n");
737 if let Some(d) = &a.description {
738 p += &format!("Content-Description: {}\r\n", one_line_value(d));
739 }
740 p += "\r\n";
741 p += &String::from_utf8_lossy(&crate::pgp::crlf(&bytes));
742 parts.push(p);
743 continue;
744 }
745 let mut p = format!(
746 "Content-Type: {mime}\r\nContent-Disposition: {disposition}; {}\r\n",
747 filename_param(a.send_name()),
748 );
749 if let Some(d) = &a.description {
750 p += &format!("Content-Description: {}\r\n", one_line_value(d));
751 }
752 p += "Content-Transfer-Encoding: base64\r\n\r\n";
753 p += &b64_wrapped(&bytes);
754 parts.push(p);
755 }
756 if let Some(orig) = original {
757 let mut p =
758 String::from("Content-Type: message/rfc822\r\nContent-Disposition: attachment\r\n\r\n");
759 p += &String::from_utf8_lossy(&crate::pgp::crlf(orig));
760 parts.push(p);
761 }
762 let boundary = {
763 let mut n = 0usize;
764 loop {
765 let b = format!("=-rmut-mixed-{}-{n}", std::process::id());
766 if !parts.iter().any(|p| p.contains(&b)) {
767 break b;
768 }
769 n += 1;
770 }
771 };
772 let mut out = format!("Content-Type: multipart/mixed; boundary=\"{boundary}\"\r\n\r\n");
773 for p in &parts {
774 out += &format!("--{boundary}\r\n");
775 out += p;
776 if !out.ends_with("\r\n") {
777 out += "\r\n";
778 }
779 }
780 out += &format!("--{boundary}--\r\n");
781 Ok(out)
782}
783
784pub fn bounce_text(original: &[u8], from: &str, to: &str, date: &str, msg_id: &str) -> String {
787 format!(
788 "Resent-From: {from}\r\nResent-Date: {date}\r\nResent-Message-ID: {msg_id}\r\nResent-To: {to}\r\n{}",
789 String::from_utf8_lossy(original),
790 )
791}
792
793pub fn list_post_address(value: &str) -> Option<String> {
799 let value = value.trim();
800 if value.eq_ignore_ascii_case("NO") {
801 return None;
802 }
803 let start = value.to_ascii_lowercase().find("mailto:")? + "mailto:".len();
804 let rest = &value[start..];
805 let addr = rest
806 .split(['>', '?', ',', ' '])
807 .next()
808 .unwrap_or(rest)
809 .trim();
810 (!addr.is_empty()).then(|| addr.to_string())
811}
812
813pub fn followup_to(to: &str, cc: &str, me: Me, subscribed: bool, my_from: &str) -> String {
818 let mut out: Vec<String> = Vec::new();
819 let mut push = |single: &mailparse::SingleInfo| {
820 if subscribed && me.is_me(&single.addr) {
821 return;
822 }
823 let written = match &single.display_name {
824 Some(name) if !name.trim().is_empty() => format!("{name} <{}>", single.addr),
825 _ => single.addr.clone(),
826 };
827 if !out.iter().any(|a| a == &written) {
828 out.push(written);
829 }
830 };
831 for field in [to, cc] {
832 let Ok(list) = mailparse::addrparse(field) else {
833 continue;
834 };
835 for addr in list.iter() {
836 match addr {
837 mailparse::MailAddr::Single(single) => push(single),
838 mailparse::MailAddr::Group(group) => group.addrs.iter().for_each(&mut push),
839 }
840 }
841 }
842 if !subscribed
843 && let Some(from) = bare_address(my_from)
844 && !out
845 .iter()
846 .any(|a| bare_address(a).is_some_and(|b| b == from))
847 {
848 out.push(my_from.trim().to_string());
849 }
850 out.join(", ")
851}
852
853pub fn group_recipients(orig_to: &str, orig_cc: &str, to: &str, me: Me, metoo: bool) -> String {
859 let mut seen: Vec<String> = addresses(to).iter().map(|a| a.to_lowercase()).collect();
860 let mut out: Vec<String> = Vec::new();
861 let mut push = |single: &mailparse::SingleInfo| {
862 let bare = single.addr.to_lowercase();
863 if seen.contains(&bare) || (!metoo && me.is_me(&bare)) {
864 return;
865 }
866 seen.push(bare);
867 out.push(match &single.display_name {
868 Some(name) if !name.trim().is_empty() => format!("{name} <{}>", single.addr),
869 _ => single.addr.clone(),
870 });
871 };
872 for field in [orig_to, orig_cc] {
873 let Ok(list) = mailparse::addrparse(field) else {
874 continue;
875 };
876 for addr in list.iter() {
877 match addr {
878 mailparse::MailAddr::Single(single) => push(single),
879 mailparse::MailAddr::Group(group) => group.addrs.iter().for_each(&mut push),
880 }
881 }
882 }
883 out.join(", ")
884}
885
886pub fn draft_envelope(text: &str, path: &Path) -> crate::message::Envelope {
893 let (head, body) = text.split_once("\n\n").unwrap_or((text.trim_end(), ""));
894 let header = |name: &str| -> String {
895 head.lines()
896 .filter_map(|l| {
897 let (k, v) = l.split_once(':')?;
898 k.trim().eq_ignore_ascii_case(name).then(|| v.trim())
899 })
900 .collect::<Vec<_>>()
901 .join(", ")
902 };
903 let bare = |field: &str| -> Vec<String> {
904 addresses(field).iter().map(|a| a.to_lowercase()).collect()
905 };
906 let from_full = header("From");
907 crate::message::Envelope {
908 file: crate::maildir::MailFile {
909 path: path.to_path_buf(),
910 is_new: false,
911 flags: crate::maildir::Flags {
912 seen: true,
913 ..Default::default()
914 },
915 size: text.len() as u64,
916 },
917 from: crate::message::short_from(&from_full),
918 from_full,
919 subject: header("Subject"),
920 date: Local::now().timestamp(),
921 msg_id: None,
922 references: Vec::new(),
923 tagged: false,
924 to: bare(&header("To")),
925 cc: [header("Cc"), header("Bcc")]
926 .iter()
927 .flat_map(|f| bare(f))
928 .collect(),
929 lines: Some(body.lines().count()),
930 list: None,
931 label: None,
932 broken: false,
933 }
934}
935
936pub fn apply_my_hdr(text: &str, my_hdr: &[String]) -> String {
943 if my_hdr.is_empty() {
944 return text.to_string();
945 }
946 let (head, body) = match text.split_once("\n\n") {
947 Some((head, body)) => (head, body),
948 None => (text.trim_end(), ""),
949 };
950 let mut lines: Vec<String> = head.lines().map(String::from).collect();
951 for entry in my_hdr {
952 let Some((name, value)) = entry.split_once(':') else {
953 continue;
954 };
955 let (name, value) = (name.trim(), value.trim());
956 if name.is_empty() {
957 continue;
958 }
959 let at = lines.iter().position(|l| {
960 l.get(..name.len())
961 .is_some_and(|k| k.eq_ignore_ascii_case(name))
962 && l.as_bytes().get(name.len()) == Some(&b':')
963 });
964 let addressy = ["to", "cc", "bcc"].contains(&name.to_lowercase().as_str());
965 match at {
966 Some(i) if addressy => {
967 let old = lines[i]
968 .split_once(':')
969 .map(|(_, v)| v.trim().to_string())
970 .unwrap_or_default();
971 lines[i] = if old.is_empty() {
972 format!("{name}: {value}")
973 } else {
974 format!("{name}: {old}, {value}")
975 };
976 }
977 Some(i) => lines[i] = format!("{name}: {value}"),
978 None => lines.push(format!("{name}: {value}")),
979 }
980 }
981 format!("{}\n\n{body}", lines.join("\n"))
982}
983
984pub fn bare_address(field: &str) -> Option<String> {
985 match mailparse::addrparse(field)
986 .ok()?
987 .into_inner()
988 .into_iter()
989 .next()?
990 {
991 mailparse::MailAddr::Single(single) => Some(single.addr),
992 mailparse::MailAddr::Group(group) => group.addrs.first().map(|a| a.addr.clone()),
993 }
994}
995
996pub fn from_address(text: &str) -> Option<String> {
998 let mail = mailparse::parse_mail(text.as_bytes()).ok()?;
999 bare_address(&crate::rfc2047::first(&mail.get_headers(), "From")?)
1000}
1001
1002fn field_addresses(value: &str, out: &mut Vec<String>) {
1003 let Ok(list) = mailparse::addrparse(value) else {
1004 return;
1005 };
1006 for addr in list.iter() {
1007 match addr {
1008 mailparse::MailAddr::Single(single) => out.push(single.addr.clone()),
1009 mailparse::MailAddr::Group(group) => {
1010 out.extend(group.addrs.iter().map(|a| a.addr.clone()));
1011 }
1012 }
1013 }
1014}
1015
1016pub fn addresses(field: &str) -> Vec<String> {
1018 let mut out = Vec::new();
1019 field_addresses(field, &mut out);
1020 out
1021}
1022
1023pub fn reverse_from(orig_to: &str, orig_cc: &str, me: Me, realname: bool) -> Option<String> {
1027 let mine = |single: &mailparse::SingleInfo| -> Option<String> {
1028 if !me.is_me(&single.addr) {
1029 return None;
1030 }
1031 Some(match &single.display_name {
1032 Some(name) if realname && !name.trim().is_empty() => {
1035 format!("{name} <{}>", single.addr)
1036 }
1037 _ => single.addr.clone(),
1038 })
1039 };
1040 for field in [orig_to, orig_cc] {
1041 let Ok(list) = mailparse::addrparse(field) else {
1042 continue;
1043 };
1044 for addr in list.iter() {
1045 match addr {
1046 mailparse::MailAddr::Single(single) => {
1047 if let Some(from) = mine(single) {
1048 return Some(from);
1049 }
1050 }
1051 mailparse::MailAddr::Group(group) => {
1052 if let Some(from) = group.addrs.iter().find_map(&mine) {
1053 return Some(from);
1054 }
1055 }
1056 }
1057 }
1058 }
1059 None
1060}
1061
1062pub fn smtp_envelope(text: &str) -> Result<(Vec<String>, String)> {
1065 let mail = mailparse::parse_mail(text.as_bytes())?;
1066 let mut rcpts = Vec::new();
1067 for header in &mail.headers {
1068 let key = header.get_key();
1069 if ["to", "cc", "bcc"].contains(&key.to_lowercase().as_str()) {
1070 field_addresses(&crate::rfc2047::value(header), &mut rcpts);
1071 }
1072 }
1073 rcpts.dedup();
1074 let (head, body) = text.split_once("\n\n").unwrap_or((text.trim_end(), ""));
1075 let mut out = String::new();
1076 let mut skipping = false;
1077 for line in head.lines() {
1078 if line.starts_with(' ') || line.starts_with('\t') {
1079 if skipping {
1081 continue;
1082 }
1083 } else {
1084 skipping = line
1085 .split_once(':')
1086 .is_some_and(|(k, _)| k.trim().eq_ignore_ascii_case("bcc"));
1087 }
1088 if !skipping {
1089 out.push_str(line);
1090 out.push('\n');
1091 }
1092 }
1093 out.push('\n');
1094 out.push_str(body);
1095 Ok((rcpts, out))
1096}
1097
1098#[cfg(test)]
1099mod tests {
1100 use super::*;
1101
1102 #[test]
1103 fn a_markdown_body_is_text_and_html() {
1104 let entity = body_entity("Hi *Jane*\n", false, true);
1105 assert!(
1106 entity.starts_with("Content-Type: multipart/alternative; boundary="),
1107 "{entity}"
1108 );
1109 let plain = entity.find("Content-Type: text/plain").expect(&entity);
1110 let html = entity.find("Content-Type: text/html").expect(&entity);
1111 assert!(plain < html, "the plain half first: {entity}");
1112 assert!(
1113 entity.contains("\r\nHi *Jane*\r\n"),
1114 "the text as typed: {entity}"
1115 );
1116 assert!(entity.contains("<em>Jane</em>"), "{entity}");
1117 assert!(entity.contains("Content-Transfer-Encoding: quoted-printable"));
1118 assert!(body_entity("Hi\n", false, false).starts_with("Content-Type: text/plain"));
1120 }
1121
1122 #[test]
1123 fn quoted_printable_keeps_lines_short_and_bytes_safe() {
1124 let long = "word ".repeat(40);
1125 let qp = quoted_printable(&format!("{long}\na=b \u{17e}luť"));
1126 assert!(
1127 qp.lines().all(|l| l.trim_end_matches('\r').len() <= 76),
1128 "{qp}"
1129 );
1130 assert!(qp.contains("=\r\n"), "a soft break: {qp}");
1131 assert!(qp.contains("=20\r\na=3Db =C5=BElu=C5=A5"), "{qp}");
1134 let back: String = qp.replace("=\r\n", "");
1135 assert!(back.starts_with("word word"), "{back}");
1136 }
1137
1138 #[test]
1139 fn the_markdown_header_is_read_and_taken_off() {
1140 let (text, said) = take_markdown("To: a@x\nX-Rmut-Markdown: yes\nSubject: s\n\nbody\n");
1141 assert_eq!(said, Some(true));
1142 assert_eq!(text, "To: a@x\nSubject: s\n\nbody\n");
1143 let (_, said) = take_markdown("To: a@x\nx-rmut-markdown: no\n\nbody\n");
1144 assert_eq!(said, Some(false));
1145 let (text, said) = take_markdown("To: a@x\n\nX-Rmut-Markdown: yes in the body\n");
1146 assert_eq!(said, None, "only the header block counts");
1147 assert!(text.contains("in the body"));
1148 }
1149
1150 #[test]
1151 fn a_forward_can_come_in_quoted() {
1152 let plain = forward_body("Ann <ann@x>", 0, "hi", "one\ntwo\n", None);
1153 assert!(plain.contains("\none\ntwo\n"), "{plain}");
1154 let quoted = forward_body("Ann <ann@x>", 0, "hi", "one\ntwo\n", Some("> "));
1155 assert!(quoted.contains("\n> one\n> two\n"), "{quoted}");
1156 assert!(quoted.starts_with("----- Forwarded message from Ann <ann@x> -----"));
1158 assert!(quoted.ends_with("----- End forwarded message -----\n"));
1159 }
1160
1161 #[test]
1162 fn the_signature_sits_under_a_dashes_line() {
1163 let body = with_signature("hello\n", "Ann\nx.example", true);
1164 assert_eq!(body, "hello\n\n-- \nAnn\nx.example\n");
1165 assert_eq!(with_signature("hello\n", "Ann", false), "hello\n\nAnn\n");
1167 assert_eq!(with_signature("", "Ann", true), "\n-- \nAnn\n");
1169 }
1170
1171 #[test]
1172 fn a_signature_comes_from_a_file_or_a_command() {
1173 let dir = std::env::temp_dir().join(format!("rmut-sig-{}", std::process::id()));
1174 std::fs::create_dir_all(&dir).unwrap();
1175 let file = dir.join("signature");
1176 std::fs::write(&file, "Ann\n\n\n").unwrap();
1177 assert_eq!(
1179 signature_text(file.to_str().unwrap()).as_deref(),
1180 Some("Ann")
1181 );
1182 assert_eq!(signature_text("echo hello|").as_deref(), Some("hello"));
1183 assert_eq!(signature_text(dir.join("gone").to_str().unwrap()), None);
1185 assert_eq!(signature_text(" "), None);
1186 assert_eq!(signature_text("true|"), None);
1187 std::fs::remove_dir_all(&dir).unwrap();
1188 }
1189
1190 #[test]
1191 fn list_post_addresses() {
1192 assert_eq!(
1193 list_post_address("<mailto:dev@example.com>").as_deref(),
1194 Some("dev@example.com")
1195 );
1196 assert_eq!(
1197 list_post_address("<mailto:dev@example.com?subject=help>").as_deref(),
1198 Some("dev@example.com")
1199 );
1200 assert_eq!(
1201 list_post_address("NOTE: <mailto:dev@example.com>, <http://x/post>").as_deref(),
1202 Some("dev@example.com")
1203 );
1204 assert_eq!(list_post_address("NO"), None);
1206 assert_eq!(list_post_address("<http://example.com/post>"), None);
1207 }
1208
1209 #[test]
1210 fn followup_to_drops_me_only_when_subscribed() {
1211 let addrs = vec!["alex@example.com".to_string()];
1212 let me = Me::addresses(&addrs);
1213 let subscribed = followup_to(
1214 "dev@example.com, Alex <alex@example.com>",
1215 "",
1216 me,
1217 true,
1218 "Alex <alex@example.com>",
1219 );
1220 assert_eq!(subscribed, "dev@example.com");
1221 let unsubscribed = followup_to(
1222 "dev@example.com",
1223 "petr@example.com",
1224 me,
1225 false,
1226 "Alex <alex@example.com>",
1227 );
1228 assert_eq!(
1229 unsubscribed,
1230 "dev@example.com, petr@example.com, Alex <alex@example.com>"
1231 );
1232 let once = followup_to(
1234 "dev@example.com, alex@example.com",
1235 "",
1236 me,
1237 false,
1238 "Alex <alex@example.com>",
1239 );
1240 assert_eq!(once, "dev@example.com, alex@example.com");
1241 }
1242
1243 #[test]
1244 fn group_reply_drops_me_and_the_sender() {
1245 let addrs = vec!["alex@example.com".to_string()];
1246 let alternates = vec![crate::pattern::Matcher::new("^jp@old\\.example\\.com$")];
1247 let me = Me::new(&addrs, &alternates);
1248 let cc = group_recipients(
1249 "Team <team@example.com>, Alex <alex@example.com>, jp@old.example.com",
1250 "boss@example.com, team@example.com",
1251 "Petr <petr@example.com>",
1252 me,
1253 false,
1254 );
1255 assert_eq!(cc, "Team <team@example.com>, boss@example.com");
1258 let cc = group_recipients(
1260 "team@example.com, alex@example.com",
1261 "",
1262 "petr@example.com",
1263 me,
1264 true,
1265 );
1266 assert_eq!(cc, "team@example.com, alex@example.com");
1267 }
1268
1269 #[test]
1270 fn text_flowed_declares_and_stuffs_the_body() {
1271 let entity = text_entity("plain\n>looks quoted\n", true);
1272 assert!(
1273 entity.starts_with("Content-Type: text/plain; charset=utf-8; format=flowed\r\n"),
1274 "{entity}"
1275 );
1276 assert!(
1277 entity.ends_with("plain\r\n >looks quoted\r\n"),
1278 "{entity:?}"
1279 );
1280 let plain = text_entity("plain\n>looks quoted\n", false);
1282 assert!(plain.starts_with("Content-Type: text/plain; charset=utf-8\r\n"));
1283 assert!(plain.ends_with("plain\r\n>looks quoted\r\n"), "{plain:?}");
1284 }
1285
1286 #[test]
1287 fn flow_plain_declares_an_unwrapped_draft() {
1288 let draft = "From: a@x\nTo: b@x\nSubject: s\n\n>quoted line\n";
1289 let out = flow_plain(draft);
1290 assert!(out.contains("Content-Type: text/plain; charset=utf-8; format=flowed\n"));
1291 assert!(out.ends_with("\n\n >quoted line\n"), "{out:?}");
1292 let typed = "From: a@x\nContent-Type: text/x-diff\n\nbody\n";
1294 assert_eq!(flow_plain(typed), typed);
1295 }
1296
1297 #[test]
1298 fn draft_envelope_reads_the_header_block() {
1299 let draft = "From: Jane Doe <jane@example.com>\n\
1300 To: Bob <BOB@work.example.com>, team@x\n\
1301 Cc: boss@x\n\
1302 Bcc: archive@x\n\
1303 Subject: quarterly\n\n\
1304 two\nlines\n";
1305 let env = draft_envelope(draft, std::path::Path::new("/tmp/draft"));
1306 assert_eq!(env.subject, "quarterly");
1307 assert_eq!(env.from_full, "Jane Doe <jane@example.com>");
1308 assert_eq!(env.to, ["bob@work.example.com", "team@x"]);
1309 assert_eq!(env.cc, ["boss@x", "archive@x"]);
1311 assert_eq!(env.lines, Some(2));
1312 let hit = |p: &str| {
1313 crate::pattern::matches_in(
1314 &crate::pattern::parse(p).unwrap(),
1315 &env,
1316 crate::pattern::Scope::default(),
1317 None,
1318 )
1319 };
1320 assert!(hit("~t @work\\.example\\.com"));
1321 assert!(hit("~c archive@"));
1322 assert!(hit("~A"));
1323 assert!(!hit("~t nobody@"));
1324 }
1325
1326 #[test]
1327 fn my_hdr_merges_into_the_draft_head() {
1328 let draft = "From: jane@example.com\nTo: bob@x\nSubject: s\n\nbody\n";
1329 let merged = apply_my_hdr(
1330 draft,
1331 &[
1332 "Organization: Acme".to_string(),
1333 "From: Jane <jane@work.example.com>".into(),
1334 "Bcc: jane@example.com".into(),
1335 "To: archive@x".into(),
1336 "bogus".into(),
1337 ],
1338 );
1339 assert_eq!(
1340 merged,
1341 "From: Jane <jane@work.example.com>\n\
1342 To: bob@x, archive@x\n\
1343 Subject: s\n\
1344 Organization: Acme\n\
1345 Bcc: jane@example.com\n\
1346 \nbody\n"
1347 );
1348 assert_eq!(apply_my_hdr(draft, &[]), draft);
1350 }
1351
1352 fn quoted() -> Quoted<'static> {
1354 Quoted {
1355 from: "Jane Doe <jane@example.com>",
1356 subject: "Lunch",
1357 message_id: Some("<m1@example.com>"),
1358 date: 1_710_151_200,
1360 }
1361 }
1362
1363 #[test]
1364 fn subjects_do_not_stack_prefixes() {
1365 let re = default_reply_regexp();
1366 assert_eq!(reply_subject("Lunch", &re), "Re: Lunch");
1367 assert_eq!(reply_subject("RE: Lunch", &re), "Re: Lunch");
1368 assert_eq!(reply_subject("Re[2]: Lunch", &re), "Re: Lunch");
1369 let aw = reply_regexp("^(re|aw|sv):[ \t]*").unwrap();
1372 assert_eq!(reply_subject("AW: Lunch", &aw), "Re: Lunch");
1373 let exact = reply_regexp("^(Re):[ \t]*").unwrap();
1374 assert_eq!(reply_subject("RE: Lunch", &exact), "Re: RE: Lunch");
1375 assert_eq!(
1376 forward_subject(DEFAULT_FORWARD_FORMAT, "ed()),
1377 "[jane@example.com: Lunch]"
1378 );
1379 }
1380
1381 #[test]
1382 fn quote_prefixes_every_line_with_the_indent_string() {
1383 assert_eq!(
1384 quote("On X, Y wrote:", DEFAULT_INDENT, "a\nb"),
1385 "On X, Y wrote:\n> a\n> b\n"
1386 );
1387 assert_eq!(quote("head", "| ", "a"), "head\n| a\n");
1388 }
1389
1390 #[test]
1391 fn an_attribution_says_who_and_when() {
1392 let m = quoted();
1393 let line = attribution(DEFAULT_ATTRIBUTION, &m);
1395 assert!(line.starts_with("On "), "{line}");
1396 assert!(line.ends_with(", Jane Doe wrote:"), "{line}");
1397 assert_eq!(render_quoted("%a", &m), "jane@example.com");
1399 assert_eq!(render_quoted("%n", &m), "Jane Doe");
1400 assert_eq!(render_quoted("%f", &m), "Jane Doe <jane@example.com>");
1401 assert_eq!(render_quoted("%s", &m), "Lunch");
1402 assert_eq!(render_quoted("%i", &m), "m1@example.com");
1403 assert_eq!(render_quoted("100%%", &m), "100%");
1404 assert_eq!(render_quoted("%q", &m), "%q");
1405 assert_eq!(render_quoted("%{%Y}", &m), "2024");
1407 assert_eq!(render_quoted("[%{%Y}] %s", &m), "[2024] Lunch");
1408 }
1409
1410 #[test]
1411 fn a_name_falls_back_to_the_address() {
1412 let m = Quoted {
1413 from: "bare@example.com",
1414 subject: "x",
1415 message_id: None,
1416 date: 0,
1417 };
1418 assert_eq!(render_quoted("%n", &m), "bare@example.com");
1419 assert_eq!(render_quoted("%i", &m), "");
1420 }
1421
1422 #[test]
1423 fn draft_text_skips_empty_optional_headers() {
1424 let text = draft_text(
1425 &DraftHeaders {
1426 from: None,
1427 to: "a@x".into(),
1428 cc: Some("".into()),
1429 subject: "s".into(),
1430 in_reply_to: None,
1431 references: None,
1432 },
1433 "hi",
1434 );
1435 assert_eq!(text, "To: a@x\nSubject: s\n\nhi\n");
1436 let text = draft_text(
1437 &DraftHeaders {
1438 from: Some("Jane Work <jane@work.example.com>".into()),
1439 to: "a@x".into(),
1440 cc: None,
1441 subject: "s".into(),
1442 in_reply_to: None,
1443 references: None,
1444 },
1445 "hi",
1446 );
1447 assert!(text.starts_with("From: Jane Work <jane@work.example.com>\nTo: a@x\n"));
1448 }
1449
1450 #[test]
1451 fn reverse_from_finds_my_address_as_it_appeared() {
1452 let addrs = vec!["jane@example.com".to_string(), "old@example.com".into()];
1453 let me = Me::addresses(&addrs);
1454 assert_eq!(
1456 reverse_from("Boss Me <Jane@example.com>, bob@y", "", me, true).as_deref(),
1457 Some("Boss Me <Jane@example.com>")
1458 );
1459 assert_eq!(
1461 reverse_from("Boss Me <Jane@example.com>, bob@y", "", me, false).as_deref(),
1462 Some("Jane@example.com")
1463 );
1464 assert_eq!(
1466 reverse_from("bob@y", "old@example.com", me, true).as_deref(),
1467 Some("old@example.com")
1468 );
1469 assert_eq!(reverse_from("bob@y, eve@z", "", me, true), None);
1470 assert_eq!(reverse_from("", "", me, true), None);
1471 }
1472
1473 #[test]
1474 fn finalize_adds_missing_headers_once() {
1475 let draft = "To: a@x\nSubject: s\n\nbody\n";
1476 let out = finalize(draft, "me@host", "<id@host>", "DATE").unwrap();
1477 assert!(out.contains("From: me@host"));
1478 assert!(out.contains("Message-ID: <id@host>"));
1479 assert!(out.contains("Date: DATE"));
1480 assert!(out.ends_with("\n\nbody\n"));
1481 let draft2 = "To: a@x\nFrom: custom@x\n\nbody\n";
1483 let out2 = finalize(draft2, "me@host", "<i>", "D").unwrap();
1484 assert!(out2.contains("From: custom@x"));
1485 assert!(!out2.contains("me@host"));
1486 }
1487
1488 #[test]
1489 fn user_agent_and_signature_placement() {
1490 let draft = "To: a@b\n\nhi";
1491 let plain = finalize_with(draft, "me@x", "<id@h>", "Mon", false).unwrap();
1492 assert!(!plain.contains("User-Agent"));
1493 let ua = finalize_with(draft, "me@x", "<id@h>", "Mon", true).unwrap();
1494 assert!(ua.contains(&user_agent_header()), "{ua}");
1495 let own = finalize_with(
1497 "To: a@b\nUser-Agent: mine\n\nhi",
1498 "me@x",
1499 "<id@h>",
1500 "Mon",
1501 true,
1502 )
1503 .unwrap();
1504 assert_eq!(own.matches("User-Agent").count(), 1, "{own}");
1505 let below = with_signature_at("the reply", "Jane", true, false);
1507 assert!(below.trim_end().ends_with("Jane"), "{below}");
1508 let above = with_signature_at("the reply", "Jane", true, true);
1509 assert!(above.starts_with("-- \nJane\n"), "{above}");
1510 assert!(above.trim_end().ends_with("the reply"), "{above}");
1511 }
1512
1513 #[test]
1514 fn finalize_rejects_missing_recipients() {
1515 assert!(finalize("Subject: s\n\nbody", "f", "<i>", "d").is_err());
1516 assert!(finalize("To: \nSubject: s\n\nbody", "f", "<i>", "d").is_err());
1517 assert!(finalize("Bcc: a@x\n\nbody", "f", "<i>", "d").is_ok());
1518 }
1519
1520 #[test]
1521 fn bare_address_drops_display_name() {
1522 assert_eq!(
1523 bare_address("Jane <jane@x.org>").as_deref(),
1524 Some("jane@x.org")
1525 );
1526 assert_eq!(bare_address("jane@x.org").as_deref(), Some("jane@x.org"));
1527 assert_eq!(bare_address(""), None);
1528 }
1529
1530 #[test]
1531 fn smtp_envelope_collects_rcpts_and_strips_bcc() {
1532 let text =
1533 "To: Alice <a@x>, b@y\nCc: c@z\nBcc: hidden@q,\n also-hidden@q\nSubject: s\n\nbody\n";
1534 let (rcpts, out) = smtp_envelope(text).unwrap();
1535 assert_eq!(
1536 rcpts,
1537 vec!["a@x", "b@y", "c@z", "hidden@q", "also-hidden@q"]
1538 );
1539 assert!(!out.to_lowercase().contains("bcc"));
1540 assert!(!out.contains("hidden@q"));
1541 assert!(out.contains("To: Alice <a@x>, b@y\n"));
1542 assert!(out.ends_with("\n\nbody\n"));
1543 }
1544
1545 #[test]
1546 fn extract_attachments_takes_the_pseudo_headers_out() {
1547 let draft = "To: a@x\nAttach: /tmp/report.pdf the Q2 numbers\n\
1548 attach: \"/tmp/two words.png\"\nAttach:\nSubject: s\n\nbody\n";
1549 let (out, files) = extract_attachments(draft);
1550 assert_eq!(out, "To: a@x\nSubject: s\n\nbody\n");
1551 assert_eq!(files.len(), 2);
1552 assert_eq!(files[0].path, PathBuf::from("/tmp/report.pdf"));
1553 assert_eq!(files[0].description.as_deref(), Some("the Q2 numbers"));
1554 assert_eq!(files[1].path, PathBuf::from("/tmp/two words.png"));
1555 assert_eq!(files[1].description, None);
1556 }
1557
1558 #[test]
1559 fn attach_lines_carry_a_type_override() {
1560 let draft = "Attach: /tmp/x.bin application/x-custom raw dump\n\
1561 Attach: /tmp/y.txt see notes\n\n";
1562 let (_, files) = extract_attachments(draft);
1563 assert_eq!(files[0].mime.as_deref(), Some("application/x-custom"));
1564 assert_eq!(files[0].description.as_deref(), Some("raw dump"));
1565 assert_eq!(files[1].mime, None);
1567 assert_eq!(files[1].description.as_deref(), Some("see notes"));
1568 let line = attach_line(&files[0]);
1570 assert_eq!(line, "Attach: /tmp/x.bin application/x-custom raw dump");
1571 let (_, roundtrip) = extract_attachments(&format!("{line}\n\n"));
1572 assert_eq!(roundtrip[0].mime.as_deref(), Some("application/x-custom"));
1573 let spaced = Attachment {
1575 path: PathBuf::from("/tmp/two words.png"),
1576 mime: Some("image/png".into()),
1577 description: None,
1578 name: None,
1579 inline: false,
1580 unlink: false,
1581 };
1582 let (_, files) = extract_attachments(&format!("{}\n\n", attach_line(&spaced)));
1583 assert_eq!(files[0].path, PathBuf::from("/tmp/two words.png"));
1584 assert_eq!(files[0].mime.as_deref(), Some("image/png"));
1585 }
1586
1587 #[test]
1588 fn extract_attachments_leaves_plain_drafts_alone() {
1589 let draft = "To: a@x\nSubject: s\n\nAttach: not a header, body text\n";
1590 let (out, files) = extract_attachments(draft);
1591 assert_eq!(out, draft);
1592 assert!(files.is_empty());
1593 }
1594
1595 #[test]
1596 fn mixed_entity_encodes_files_and_original() {
1597 use mailparse::MailHeaderMap;
1598 let dir = std::env::temp_dir().join(format!("rmut-attach-test-{}", std::process::id()));
1599 std::fs::create_dir_all(&dir).unwrap();
1600 let blob: Vec<u8> = (0..=255u8).collect();
1601 std::fs::write(dir.join("blob.bin"), &blob).unwrap();
1602 let files = [Attachment {
1603 path: dir.join("blob.bin"),
1604 mime: None,
1605 description: Some("raw bytes".into()),
1606 name: None,
1607 inline: false,
1608 unlink: false,
1609 }];
1610 let orig = b"From: jane@x\r\nSubject: hi\r\n\r\noriginal body\r\n";
1611 let entity = mixed_entity("see attached", &files, Some(orig), false, false).unwrap();
1612 let mail = mailparse::parse_mail(entity.as_bytes()).unwrap();
1613 assert_eq!(mail.ctype.mimetype, "multipart/mixed");
1614 assert_eq!(mail.subparts.len(), 3);
1615 assert_eq!(mail.subparts[0].get_body().unwrap().trim(), "see attached");
1616 let file = &mail.subparts[1];
1617 assert_eq!(file.ctype.mimetype, "application/octet-stream");
1618 assert_eq!(file.get_body_raw().unwrap(), blob);
1619 let disp = file.get_headers().get_first_value("Content-Disposition");
1620 assert!(disp.unwrap().contains("filename=\"blob.bin\""));
1621 assert_eq!(
1622 file.get_headers()
1623 .get_first_value("Content-Description")
1624 .as_deref(),
1625 Some("raw bytes")
1626 );
1627 assert_eq!(mail.subparts[2].ctype.mimetype, "message/rfc822");
1628 assert!(
1629 mail.subparts[2]
1630 .get_body()
1631 .unwrap()
1632 .contains("original body")
1633 );
1634 std::fs::remove_dir_all(&dir).unwrap();
1635 }
1636
1637 #[test]
1638 fn mixed_entity_reports_a_missing_file() {
1639 let files = [Attachment {
1640 path: PathBuf::from("/nonexistent/nope.pdf"),
1641 mime: None,
1642 description: None,
1643 name: None,
1644 inline: false,
1645 unlink: false,
1646 }];
1647 let err = mixed_entity("hi", &files, None, false, false).unwrap_err();
1648 assert!(err.to_string().contains("/nonexistent/nope.pdf"));
1649 }
1650
1651 #[test]
1652 fn bounce_text_prepends_resent_headers() {
1653 let orig = b"From: jane@x\nSubject: hi\n\nbody\n";
1654 let out = bounce_text(orig, "Me <me@x>", "bob@y", "DATE", "<id@x>");
1655 assert!(out.starts_with("Resent-From: Me <me@x>\r\n"));
1656 assert!(out.contains("Resent-Date: DATE\r\n"));
1657 assert!(out.contains("Resent-To: bob@y\r\n"));
1658 assert!(out.ends_with("From: jane@x\nSubject: hi\n\nbody\n"));
1659 }
1660
1661 #[test]
1662 fn smtp_envelope_without_bcc_is_unchanged() {
1663 let text = "To: a@x\nSubject: s\n\nbody\n";
1664 let (rcpts, out) = smtp_envelope(text).unwrap();
1665 assert_eq!(rcpts, vec!["a@x"]);
1666 assert_eq!(out, text);
1667 }
1668
1669 #[test]
1670 fn attach_line_options_round_trip() {
1671 let mut a = Attachment::of(PathBuf::from("/tmp/q2 report.pdf"));
1672 a.mime = Some("application/pdf".into());
1673 a.name = Some("report.pdf".into());
1674 a.inline = true;
1675 a.unlink = true;
1676 a.description = Some("the Q2 numbers".into());
1677 let line = attach_line(&a);
1678 assert_eq!(
1679 line,
1680 "Attach: \"/tmp/q2 report.pdf\" application/pdf @name=\"report.pdf\" @inline @unlink the Q2 numbers"
1681 );
1682 let (_, back) = extract_attachments(&format!("To: x\n{line}\n\nbody"));
1683 assert_eq!(back.len(), 1);
1684 let b = &back[0];
1685 assert_eq!(b.path, a.path);
1686 assert_eq!(b.mime.as_deref(), Some("application/pdf"));
1687 assert_eq!(b.name.as_deref(), Some("report.pdf"));
1688 assert!(b.inline && b.unlink);
1689 assert_eq!(b.description.as_deref(), Some("the Q2 numbers"));
1690 let (_, plain) = extract_attachments("Attach: /tmp/a.txt text/plain @home notes\n\n");
1693 assert!(!plain[0].inline && plain[0].name.is_none());
1694 assert_eq!(plain[0].description.as_deref(), Some("@home notes"));
1695 }
1696
1697 #[test]
1698 fn mixed_entity_honours_name_disposition_and_rfc822() {
1699 let dir = std::env::temp_dir().join(format!("rmut-attach-opts-{}", std::process::id()));
1700 std::fs::create_dir_all(&dir).unwrap();
1701 let file = dir.join("data.bin");
1702 std::fs::write(&file, b"xyz").unwrap();
1703 let msg = dir.join("1.host:2,S");
1704 std::fs::write(&msg, "From: a@x\nSubject: inner\n\nhello\n").unwrap();
1705 let mut a = Attachment::of(file.clone());
1706 a.name = Some("renamed.bin".into());
1707 a.inline = true;
1708 let mut m = Attachment::of(msg.clone());
1709 m.mime = Some("message/rfc822".into());
1710 let entity = mixed_entity("see attached", &[a, m], None, false, false).unwrap();
1711 assert!(
1712 entity.contains("Content-Disposition: inline; filename=\"renamed.bin\""),
1713 "{entity}"
1714 );
1715 assert!(
1716 entity.contains(
1717 "Content-Type: message/rfc822\r\nContent-Disposition: attachment\r\n\r\nFrom: a@x"
1718 ),
1719 "{entity}"
1720 );
1721 assert!(
1722 !entity.contains("filename=\"1.host"),
1723 "a message has no filename"
1724 );
1725 let _ = std::fs::remove_dir_all(&dir);
1726 }
1727
1728 #[test]
1729 fn attachment_names_and_descriptions_stay_one_header() {
1730 let dir = tempfile::tempdir().unwrap();
1731 let file = dir.path().join("x.bin");
1732 std::fs::write(&file, b"xyz").unwrap();
1733 let named = |name: &str, desc: &str| {
1734 let mut a = Attachment::of(file.clone());
1735 a.name = Some(name.into());
1736 a.description = Some(desc.into());
1737 let entity = mixed_entity("hi", &[a], None, false, false).unwrap();
1738 let raw = format!("MIME-Version: 1.0\r\n{entity}");
1739 let mail = mailparse::parse_mail(raw.as_bytes()).unwrap();
1740 let part = &mail.subparts[1];
1741 let filename = part.get_content_disposition().params["filename"].clone();
1742 let headers: Vec<String> = part.headers.iter().map(|h| h.get_key()).collect();
1743 use mailparse::MailHeaderMap as _;
1744 (
1745 filename,
1746 part.headers.get_first_value("Content-Description"),
1747 headers,
1748 )
1749 };
1750 let (name, desc, headers) = named("say \"hi\".txt", "ok\r\nContent-Type: text/html");
1751 assert_eq!(name, "say \"hi\".txt");
1752 assert_eq!(desc.as_deref(), Some("ok Content-Type: text/html"));
1753 assert_eq!(headers.iter().filter(|k| *k == "Content-Type").count(), 1);
1754 let (name, _, _) = named("Příloha č. 1.pdf", "d");
1755 assert_eq!(name, "Příloha č. 1.pdf");
1756 }
1757}