1use std::collections::{HashMap, HashSet};
10use std::io::Write as _;
11use std::mem;
12use std::path::{Path, PathBuf};
13use std::process::{Command, Stdio};
14use std::sync::Mutex;
15use std::time::{Duration, Instant, SystemTime};
16
17use anyhow::{Context, Result};
18use rmut_core::config::{Account, Config};
19use rmut_core::message::Envelope;
20use rmut_core::notice::{Notice, NoticeSink};
21use rmut_core::pattern::{self, Pattern};
22use rmut_core::remote::{self, Remote};
23use rmut_core::{alias, compose, hdrcache, maildir, mbox, message, pgp, smtp, thread};
24
25mod ask;
26mod commands;
27mod drafts;
28mod function;
29#[cfg(test)]
30mod tests;
31mod worker;
32
33pub use ask::{Answer, Ask, AskKind, Key, PatternOp, Request, Wants};
34pub use commands::CommandRun;
35pub use function::{FrontOp, Function, Outcome, PageSpot, SidebarOp};
36pub use worker::{Done, Facts, Imap, Job, Manage};
37
38const UNDO_MAX_STEPS: usize = 32;
42const UNDO_MAX_MARKS: usize = 100_000;
43
44pub struct Msg {
45 pub env: message::Envelope,
46 pub dirty: bool,
48}
49
50impl Msg {
51 pub fn pending(&self) -> bool {
52 self.dirty || self.env.file.flags.deleted
53 }
54}
55
56#[derive(Clone)]
60pub struct MsgMark {
61 pub path: PathBuf,
62 pub flags: maildir::Flags,
63 pub is_new: bool,
64 pub tagged: bool,
65 pub dirty: bool,
66}
67
68pub struct UndoStep {
71 pub what: String,
72 pub marks: Vec<MsgMark>,
73 pub sel: Option<PathBuf>,
76 pub created: Vec<PathBuf>,
79 pub note: Option<String>,
82 pub rewritten: Vec<(PathBuf, Vec<u8>)>,
85}
86
87#[derive(Clone, Copy, PartialEq, Eq, Debug)]
88pub enum SortKey {
89 Date,
90 From,
91 Subject,
92 Size,
93 Threads,
94 Label,
96 To,
98 Unsorted,
101}
102
103impl SortKey {
104 pub fn name(self) -> &'static str {
105 match self {
106 SortKey::Date => "date",
107 SortKey::From => "from",
108 SortKey::Subject => "subject",
109 SortKey::Size => "size",
110 SortKey::Threads => "threads",
111 SortKey::Label => "label",
112 SortKey::To => "to",
113 SortKey::Unsorted => "unsorted",
114 }
115 }
116}
117
118#[derive(Clone, Copy, PartialEq, Eq)]
119pub enum ThreadOp {
120 Delete,
121 Undelete,
122 Tag,
123 Read,
125}
126
127fn mailbox_exists(spec: &str) -> bool {
131 match remote::parse_spec(spec) {
132 Some(_) => true,
133 None => expand_tilde(spec).join("cur").is_dir(),
134 }
135}
136
137pub struct Hook {
140 patterns: Vec<Pattern>,
141 pub value: String,
143}
144
145#[derive(Clone, Copy, PartialEq, Eq)]
146pub enum ComposeKind {
147 New,
148 Reply,
149 GroupReply,
150 ListReply,
152 Forward,
153}
154
155pub struct ComposeBase {
158 pub path: PathBuf,
159 pub reply_to: String,
160 pub from_hdr: String,
163 pub has_reply_to: bool,
166 pub orig_to: String,
167 pub orig_cc: String,
168 pub list_post: Option<String>,
170 pub followup_to: String,
173 pub from_addr: String,
175 pub from_display: String,
176 pub subject: String,
177 pub date: i64,
178 pub msg_id: Option<String>,
179 pub references: Vec<String>,
180}
181
182pub struct ComposeSetup {
183 pub kind: ComposeKind,
184 pub base: Option<ComposeBase>,
185 pub to: Option<String>,
186 pub cc: Option<String>,
188 pub bcc: Option<String>,
189 pub subject_prefill: Option<String>,
192 pub subject: Option<String>,
194 pub fwd_attach: Option<bool>,
196}
197
198#[derive(Clone, Copy, PartialEq, Eq, Debug)]
200pub enum Security {
201 None,
202 Sign,
203 Encrypt,
204 Both,
205}
206
207impl Security {
208 pub fn label(self) -> &'static str {
209 match self {
210 Security::None => "",
211 Security::Sign => "sign",
212 Security::Encrypt => "encrypt",
213 Security::Both => "sign+encrypt",
214 }
215 }
216}
217
218pub struct Compose {
220 pub path: PathBuf,
221 pub recall_source: Option<PathBuf>,
223 pub security: Security,
224 pub attach: Option<PathBuf>,
227 pub hidden_head: Option<String>,
230 pub fcc: Option<String>,
233}
234
235pub struct Held {
240 pub state: Compose,
241 pub text: String,
242 pub fcc: Option<String>,
245 pub label: String,
247 pub due: Instant,
248}
249
250enum Pending {
254 CheckNew,
256 Sync { purge: bool },
259 Again(Again),
262 Counts(Vec<String>),
264}
265
266#[derive(Clone)]
269enum Again {
270 View,
272 Copy {
273 input: String,
274 delete: bool,
275 tagged: bool,
276 decode: bool,
277 },
278 Pipe {
279 command: String,
280 tagged: bool,
281 },
282 Print {
283 tagged: bool,
284 },
285 Bounce {
286 to: String,
287 tagged: bool,
288 },
289}
290
291pub struct Session {
293 pub dir: PathBuf,
296 pub title: String,
299 imap: Option<Imap>,
303 pub mbox: Option<mbox::Mbox>,
305 pub msgs: Vec<Msg>,
306 pub visible: Vec<usize>,
308 pub sel: usize,
310 pub sort: SortKey,
311 pub sort_rev: bool,
312 pub limit: Option<(String, Vec<Pattern>)>,
313 pub last_search: Option<Vec<Pattern>>,
314 pub search_rev: bool,
317 pub thread_depth: Vec<usize>,
320 pub thread_root: Vec<usize>,
321 pub thread_pseudo: Vec<bool>,
324 pub thread_parent: Vec<Option<usize>>,
328 pub thread_children: Vec<Vec<usize>>,
329 pub thread_members: Vec<Vec<usize>>,
330 pub collapsed: HashSet<PathBuf>,
332 undo: Vec<UndoStep>,
335 pub config: Config,
336 pub my_addresses: Vec<String>,
339 pub lists: Vec<pattern::Matcher>,
342 pub subscribed: Vec<pattern::Matcher>,
345 pub alternates: Vec<pattern::Matcher>,
348 body_hits: HashMap<String, HashSet<u32>>,
351 pub idle: Option<remote::IdleWatch>,
353 backfill: Option<remote::Backfill>,
355 mailbox_new: HashMap<String, usize>,
358 unseen: HashMap<String, usize>,
361 pub read_only: bool,
363 pub read_only_session: bool,
367 pub(crate) dir_mtimes: (Option<SystemTime>, Option<SystemTime>),
369 pub message_hooks: Vec<Hook>,
374 pub reply_hooks: Vec<Hook>,
375 fcc_hooks: Vec<Hook>,
376 crypt_hooks: Vec<(pattern::Matcher, String)>,
377 pub display: message::Display,
380 outbox: Vec<Held>,
383 pub quote_re: regex_lite::Regex,
386 pub reply_re: regex_lite::Regex,
388 attach_re: regex_lite::Regex,
390 pending: Option<Pending>,
393 progress_noted: bool,
396 aborted: bool,
399 quit_default: bool,
402 setup: Option<ComposeSetup>,
405 draft: Option<Compose>,
409 staged: Option<(PathBuf, String)>,
413 attach_confirmed: bool,
416 hook_base: Option<Box<Config>>,
419 active_message_hooks: Vec<usize>,
422 requests: Vec<Request>,
424 notices: Box<dyn NoticeSink>,
427}
428
429struct Silence;
431
432impl NoticeSink for Silence {
433 fn notice(&mut self, _notice: Notice) {}
434
435 fn latest(&self) -> Option<&Notice> {
436 None
437 }
438
439 fn clear(&mut self) {}
440}
441
442impl Session {
443 pub fn open(dir: &Path, config: Config) -> Result<(Session, Vec<String>)> {
449 let (envelopes, skipped) = hdrcache::load_envelopes(dir)?;
451 let mut msgs: Vec<Msg> = envelopes
452 .into_iter()
453 .map(|env| Msg { env, dirty: false })
454 .collect();
455 msgs.sort_by_key(|m| m.env.date);
457 let visible: Vec<usize> = (0..msgs.len()).collect();
458 let sel = msgs
460 .iter()
461 .position(|m| m.env.file.is_new)
462 .unwrap_or(visible.len().saturating_sub(1));
463 let mut warnings = Vec::new();
464 if skipped > 0 {
465 warnings.push(format!("{skipped} unreadable message(s) skipped"));
466 }
467 let count = msgs.len();
468 let mut me: Vec<String> = config
469 .identity
470 .email
471 .iter()
472 .chain(config.accounts.iter().map(|a| &a.user))
473 .chain(
474 config
475 .accounts
476 .iter()
477 .filter_map(|a| a.identity.as_ref().and_then(|i| i.email.as_ref())),
478 )
479 .chain(config.identities.iter().filter_map(|r| r.email.as_ref()))
480 .map(|a| a.to_lowercase())
481 .collect();
482 if let Ok(email) = std::env::var("EMAIL") {
483 me.push(email.to_lowercase());
484 }
485 let mut session = Session {
486 dir: dir.to_path_buf(),
487 title: dir.display().to_string(),
488 imap: None,
489 mbox: None,
490 msgs,
491 visible,
492 sel,
493 sort: SortKey::Date,
494 sort_rev: false,
495 limit: None,
496 last_search: None,
497 search_rev: false,
498 thread_depth: vec![0; count],
499 thread_root: (0..count).collect(),
500 thread_pseudo: vec![false; count],
501 thread_parent: vec![None; count],
502 thread_children: vec![Vec::new(); count],
503 thread_members: (0..count).map(|i| vec![i]).collect(),
504 collapsed: HashSet::new(),
505 undo: Vec::new(),
506 lists: config.list_matchers(),
507 subscribed: config.subscribed_matchers(),
508 alternates: config.alternate_matchers(),
509 my_addresses: me,
510 body_hits: HashMap::new(),
511 idle: None,
512 backfill: None,
513 mailbox_new: HashMap::new(),
514 unseen: HashMap::new(),
515 read_only: false,
516 read_only_session: false,
517 dir_mtimes: dir_mtimes(dir),
518 message_hooks: Vec::new(),
519 reply_hooks: Vec::new(),
520 fcc_hooks: Vec::new(),
521 crypt_hooks: Vec::new(),
522 display: display_from_config(&config),
523 outbox: Vec::new(),
524 quote_re: default_quote_re(),
525 reply_re: compose::default_reply_regexp(),
526 attach_re: default_attach_re(),
527 config,
528 pending: None,
529 progress_noted: false,
530 aborted: false,
531 quit_default: true,
532 setup: None,
533 draft: None,
534 staged: None,
535 attach_confirmed: false,
536 hook_base: None,
537 active_message_hooks: Vec::new(),
538 requests: Vec::new(),
539 notices: Box::new(Silence),
540 };
541 session.apply_timeouts();
542 let mut hook_warnings = Vec::new();
543 session.quote_re = quote_re_from_config(&session.config, &mut hook_warnings);
544 session.reply_re = reply_re_from_config(&session.config, &mut hook_warnings);
545 session.attach_re = attach_re_from_config(&session.config, &mut hook_warnings);
546 session.compile_hooks_from_config(&mut hook_warnings);
547 hook_warnings.append(&mut warnings);
548 let mut warnings = hook_warnings;
549 if let Some(spec) = session.config.index.sort.clone() {
550 match parse_sort(&spec) {
551 Some((sort, rev)) => {
552 session.sort = sort;
553 session.sort_rev = rev;
554 session.resort(None);
555 }
556 None => warnings.push(format!("unknown sort {spec:?} in config")),
557 }
558 }
559 Ok((session, warnings))
560 }
561
562 pub fn open_spec(
569 spec: &str,
570 config: Config,
571 progress: remote::Progress,
572 ) -> Result<(Session, Vec<String>)> {
573 rmut_core::net::set_timeouts(config.net.connect_timeout, config.net.timeout);
576 rmut_core::net::set_trust(
577 config.net.system_cas,
578 config.net.certificate_file.as_deref().map(expand_tilde),
579 );
580 match remote::parse_spec(spec) {
581 Some((account_name, mailbox)) => {
582 let account = config
583 .account(account_name)
584 .with_context(|| format!("no account {account_name} in config"))?
585 .clone();
586 let password = account_password(&account)?;
587 let remote = Remote::open(&account, mailbox, &password, progress)?;
588 let cache = remote.cache.clone();
589 let (mut session, warnings) = Session::open(&cache, config)?;
590 session.title = remote.spec.clone();
591 session.idle = Some(remote::idle_watch(&account, &remote.mailbox, &password));
594 session.imap = Some(Imap::new(remote));
595 Ok((session, warnings))
596 }
597 None => {
598 let path = expand_tilde(spec);
599 if path.is_file() {
600 return Session::open_mbox(&path, config);
601 }
602 Session::open(&path, config)
603 }
604 }
605 }
606
607 pub fn open_mbox(path: &Path, config: Config) -> Result<(Session, Vec<String>)> {
610 let mbox = mbox::Mbox::open(path)?;
611 let cache = mbox.cache.clone();
612 let (mut session, warnings) = Session::open(&cache, config)?;
613 session.title = path.display().to_string();
614 session.mbox = Some(mbox);
615 Ok((session, warnings))
616 }
617
618 pub fn recompile(&mut self) -> Vec<String> {
621 let mut warnings = Vec::new();
622 self.apply_timeouts();
623 self.display = display_from_config(&self.config);
624 self.quote_re = quote_re_from_config(&self.config, &mut warnings);
625 self.reply_re = reply_re_from_config(&self.config, &mut warnings);
626 self.attach_re = attach_re_from_config(&self.config, &mut warnings);
627 self.compile_hooks_from_config(&mut warnings);
628 self.lists = self.config.list_matchers();
629 self.subscribed = self.config.subscribed_matchers();
630 self.alternates = self.config.alternate_matchers();
631 warnings
632 }
633
634 fn apply_timeouts(&self) {
638 rmut_core::net::set_timeouts(self.config.net.connect_timeout, self.config.net.timeout);
639 rmut_core::net::set_trust(
640 self.config.net.system_cas,
641 self.config
642 .net
643 .certificate_file
644 .as_deref()
645 .map(expand_tilde),
646 );
647 }
648
649 fn compile_hooks_from_config(&mut self, warnings: &mut Vec<String>) {
652 self.message_hooks = compile_hooks(
653 "message-hook",
654 self.config
655 .message_hooks
656 .iter()
657 .map(|h| (&h.pattern, &h.command)),
658 warnings,
659 );
660 self.reply_hooks = compile_hooks(
661 "reply-hook",
662 self.config
663 .reply_hooks
664 .iter()
665 .map(|h| (&h.pattern, &h.command)),
666 warnings,
667 );
668 self.fcc_hooks = compile_hooks(
669 "fcc-hook",
670 self.config
671 .fcc_hooks
672 .iter()
673 .map(|h| (&h.pattern, &h.mailbox)),
674 warnings,
675 );
676 self.crypt_hooks = self
677 .config
678 .crypt_hooks
679 .iter()
680 .map(|h| (pattern::Matcher::new(&h.address), h.key.clone()))
681 .collect();
682 }
683
684 pub fn switch_to(&mut self, spec: &str, progress: remote::Progress) -> Result<Vec<String>> {
694 let reuse = match remote::parse_spec(spec) {
695 Some((account, mailbox))
696 if self
697 .imap
698 .as_ref()
699 .is_some_and(|imap| imap.facts.account.name == account)
700 && self
701 .imap
702 .as_mut()
703 .is_some_and(|imap| imap.blocking(Job::Switch(mailbox.into())).is_ok()) =>
704 {
705 self.imap.take()
706 }
707 _ => None,
708 };
709 let config = self.config.clone();
710 let (mut next, warnings) = match &reuse {
711 Some(imap) => Session::open(&imap.facts.cache.clone(), config)?,
712 None => Session::open_spec(spec, config, progress)?,
713 };
714 if let Some(imap) = reuse {
715 next.title = imap.facts.spec.clone();
716 if let Ok(password) = account_password(&imap.facts.account) {
718 next.idle = Some(remote::idle_watch(
719 &imap.facts.account,
720 &imap.facts.mailbox,
721 &password,
722 ));
723 }
724 next.imap = Some(imap);
725 }
726 next.read_only_session = self.read_only_session;
729 next.read_only = self.read_only_session;
730 next.notices = mem::replace(&mut self.notices, Box::new(Silence));
731 *self = next;
732 Ok(warnings)
733 }
734
735 pub fn install_notices(&mut self, sink: Box<dyn NoticeSink>) {
738 self.notices = sink;
739 }
740
741 pub fn clear_notice(&mut self) {
744 self.notices.clear();
745 }
746
747 pub fn new_count(&self) -> usize {
748 self.msgs.iter().filter(|m| m.env.file.is_new).count()
749 }
750
751 pub fn deleted_count(&self) -> usize {
752 self.msgs
753 .iter()
754 .filter(|m| m.env.file.flags.deleted)
755 .count()
756 }
757
758 pub fn pending_count(&self) -> usize {
759 self.msgs.iter().filter(|m| m.pending()).count()
760 }
761
762 fn thread_has_unread(&self, root: usize) -> bool {
764 self.thread_root
765 .iter()
766 .enumerate()
767 .filter(|&(_, &r)| r == root)
768 .any(|(i, _)| !self.msgs[i].env.file.flags.seen)
769 }
770
771 fn first_unread_in_thread(&self, root: usize) -> Option<PathBuf> {
774 self.thread_root
775 .iter()
776 .enumerate()
777 .filter(|&(_, &r)| r == root)
778 .map(|(i, _)| i)
779 .find(|&i| !self.msgs[i].env.file.flags.seen)
780 .map(|i| self.msgs[i].env.file.path.clone())
781 }
782
783 pub fn thread_info(&self, mi: usize) -> (usize, Option<usize>) {
785 if self.sort != SortKey::Threads {
786 return (0, None);
787 }
788 let depth = self.thread_depth.get(mi).copied().unwrap_or(0);
789 if depth == 0 && self.collapsed.contains(&self.msgs[mi].env.file.path) {
790 let hidden = self
791 .thread_root
792 .iter()
793 .enumerate()
794 .filter(|&(j, &r)| r == mi && j != mi)
795 .count();
796 if hidden > 0 {
797 return (0, Some(hidden));
798 }
799 }
800 (depth, None)
801 }
802
803 pub fn select(&mut self, index: usize) {
804 if self.visible.is_empty() {
805 return;
806 }
807 self.sel = index.min(self.visible.len() - 1);
808 }
809
810 pub fn cur_mut(&mut self) -> Option<&mut Msg> {
811 let i = self.visible.get(self.sel).copied()?;
812 self.msgs.get_mut(i)
813 }
814
815 pub fn selected_path(&self) -> Option<PathBuf> {
816 self.visible
817 .get(self.sel)
818 .map(|&i| self.msgs[i].env.file.path.clone())
819 }
820
821 pub fn deny_readonly(&mut self) -> bool {
824 if self.read_only {
825 self.error("Mailbox is read-only.");
826 }
827 self.read_only
828 }
829
830 pub fn mark_read(&mut self) {
831 if self.read_only {
832 return;
833 }
834 if let Some(m) = self.cur_mut()
835 && (m.env.file.is_new || !m.env.file.flags.seen)
836 {
837 m.env.file.is_new = false;
838 m.env.file.flags.seen = true;
839 m.dirty = true;
840 }
841 }
842
843 pub fn scope(&self, position: pattern::Position) -> pattern::Scope<'_> {
846 pattern::Scope {
847 me: self.me(),
848 lists: &self.lists,
849 subscribed: &self.subscribed,
850 position,
851 thread: None,
852 }
853 }
854
855 pub fn scope_at(&self, position: pattern::Position, mi: usize) -> pattern::Scope<'_> {
859 let thread = (self.sort == SortKey::Threads && mi < self.msgs.len()).then(|| {
860 let root = self.thread_root.get(mi).copied().unwrap_or(mi);
861 pattern::ThreadView {
862 members: self.thread_members.get(root).map_or(&[][..], Vec::as_slice),
863 parent: self.thread_parent.get(mi).copied().flatten(),
864 children: self.thread_children.get(mi).map_or(&[][..], Vec::as_slice),
865 collapsed: self.thread_depth.get(mi) == Some(&0)
866 && self.collapsed.contains(&self.msgs[mi].env.file.path),
867 envs: self,
868 }
869 });
870 pattern::Scope {
871 thread,
872 ..self.scope(position)
873 }
874 }
875
876 pub fn me(&self) -> pattern::Me<'_> {
878 pattern::Me::new(&self.my_addresses, &self.alternates)
879 }
880
881 fn env_matches_at(
886 &self,
887 patterns: &[Pattern],
888 env: &Envelope,
889 pos: pattern::Position,
890 mi: usize,
891 ) -> bool {
892 pattern::matches_in(
893 patterns,
894 env,
895 self.scope_at(pos, mi),
896 Some(&|env: &Envelope, m: &pattern::Matcher| {
897 let set = self.body_hits.get(m.raw())?;
898 let uid = remote::uid_of(&env.file.path)?;
899 Some(set.contains(&uid))
900 }),
901 )
902 }
903
904 fn positions(&self) -> Vec<pattern::Position> {
909 let mut seen: HashMap<&str, usize> = HashMap::new();
910 for m in &self.msgs {
911 if let Some(id) = m.env.msg_id.as_deref() {
912 *seen.entry(id).or_default() += 1;
913 }
914 }
915 let mut numbers = vec![0usize; self.msgs.len()];
916 for (n, &mi) in self.visible.iter().enumerate() {
917 if let Some(slot) = numbers.get_mut(mi) {
918 *slot = n + 1;
919 }
920 }
921 let current = self
922 .visible
923 .get(self.sel)
924 .and_then(|&mi| numbers.get(mi).copied())
925 .unwrap_or(0);
926 let last = self.visible.len();
927 (0..self.msgs.len())
928 .map(|i| pattern::Position {
929 number: numbers[i],
930 current,
931 last,
932 duplicate: self.msgs[i]
933 .env
934 .msg_id
935 .as_deref()
936 .is_some_and(|id| seen.get(id).copied().unwrap_or(0) > 1),
937 })
938 .collect()
939 }
940
941 pub fn resolve_body_terms(&mut self, patterns: &[Pattern]) {
946 if self.imap.is_none() {
947 return;
948 }
949 for term in pattern::body_terms(patterns) {
950 let simple = !term
951 .chars()
952 .any(|c| r".*+?[](){}|^$\".contains(c) || !c.is_ascii());
953 if !simple || self.body_hits.contains_key(&term) {
954 continue;
955 }
956 let Some(imap) = &mut self.imap else { return };
957 if let Ok(Done::Uids(uids)) = imap.blocking(Job::SearchBody(term.clone())) {
958 self.body_hits.insert(term, uids.into_iter().collect());
959 }
960 }
961 }
962
963 pub fn rescan(&mut self) {
966 let Ok(files) = maildir::scan(&self.dir) else {
967 return;
968 };
969 let keep = self.selected_path();
970 let mut old: std::collections::HashMap<PathBuf, Msg> = self
971 .msgs
972 .drain(..)
973 .map(|m| (m.env.file.path.clone(), m))
974 .collect();
975 let mut arrived = 0usize;
976 for file in files {
977 match old.remove(&file.path) {
978 Some(prev) if prev.pending() => self.msgs.push(prev),
979 Some(mut prev) => {
980 prev.env.file = file;
982 self.msgs.push(prev);
983 }
984 None => {
985 if let Ok(env) = message::envelope(file) {
986 if env.file.is_new {
987 arrived += 1;
988 }
989 self.msgs.push(Msg { env, dirty: false });
990 }
991 }
992 }
993 }
994 self.dir_mtimes = dir_mtimes(&self.dir);
995 self.resort(keep);
996 if arrived > 0 {
997 let title = self.title.clone();
998 self.notify(Notice::NewMail(format!("new mail in {title} (+{arrived})")));
999 self.run_new_mail_command(&title, arrived);
1000 }
1001 }
1002
1003 fn run_new_mail_command(&self, mailbox: &str, count: usize) {
1006 let Some(cmd) = &self.config.mail.new_mail_command else {
1007 return;
1008 };
1009 let cmd = cmd
1010 .replace("%f", &format!("'{}'", mailbox.replace('\'', r"'\''")))
1011 .replace("%n", &count.to_string());
1012 let _ = Command::new("sh")
1013 .arg("-c")
1014 .arg(cmd)
1015 .stdout(Stdio::null())
1016 .stderr(Stdio::null())
1017 .spawn();
1018 }
1019
1020 pub fn check_new_mail(&mut self) {
1027 self.settle();
1028 let backfilling = self.backfill.as_ref().is_some_and(|b| !b.done());
1029 if !backfilling && self.start(Job::CheckNew, Pending::CheckNew) {
1033 return;
1034 }
1035 self.check_local_mail();
1036 }
1037
1038 fn check_local_mail(&mut self) {
1041 self.maybe_backfill();
1042 if let Some(mbox) = &mut self.mbox {
1043 if let Err(err) = mbox.refresh() {
1045 self.error(format!("mbox: {err:#}"));
1046 }
1047 }
1048 self.check_other_mailboxes();
1049 self.requests.push(Request::MailboxesChanged);
1051 if dir_mtimes(&self.dir) != self.dir_mtimes {
1052 self.rescan();
1053 }
1054 self.refresh_unseen();
1056 }
1057
1058 pub fn poll_network(&mut self) {
1062 if let Some(line) = self.imap.as_mut().and_then(Imap::take_progress) {
1063 if !self.aborted {
1066 self.note(format!("{line} (Ctrl+G aborts)"));
1067 self.progress_noted = true;
1068 }
1069 }
1070 let Some(done) = self.imap.as_mut().and_then(Imap::collect) else {
1071 return;
1072 };
1073 if mem::take(&mut self.progress_noted) {
1076 self.clear_notice();
1077 }
1078 if let Some(pending) = self.pending.take() {
1079 self.resume(pending, done);
1080 }
1081 }
1082
1083 pub fn busy(&self) -> Option<&'static str> {
1085 self.imap.as_ref().and_then(Imap::busy)
1086 }
1087
1088 pub fn abort_network(&mut self) {
1092 let Some(what) = self.busy() else {
1093 return;
1094 };
1095 if let Some(imap) = &self.imap {
1096 imap.abort();
1097 }
1098 self.note(format!("aborted: {what}"));
1099 self.progress_noted = false;
1102 self.aborted = true;
1103 }
1104
1105 fn start(&mut self, job: Job, pending: Pending) -> bool {
1109 let Some(imap) = &mut self.imap else {
1110 return false;
1111 };
1112 let what = job.what();
1113 match imap.start(job) {
1114 Ok(()) => {
1115 self.note(format!("{what}... (Ctrl+G aborts)"));
1119 self.progress_noted = true;
1120 self.pending = Some(pending);
1121 true
1122 }
1123 Err(err) => {
1124 self.error(format!("imap: {err:#}"));
1125 false
1126 }
1127 }
1128 }
1129
1130 fn settle(&mut self) {
1133 while let Some(pending) = self.pending.take() {
1134 let Some(imap) = &mut self.imap else { break };
1135 let done = imap.wait();
1136 if mem::take(&mut self.progress_noted) {
1137 self.clear_notice();
1138 }
1139 self.resume(pending, done);
1140 }
1141 }
1142
1143 fn resume(&mut self, pending: Pending, done: Result<Done>) {
1144 if mem::take(&mut self.aborted) && done.is_err() {
1145 return;
1148 }
1149 match pending {
1150 Pending::CheckNew => {
1151 if let Err(err) = done {
1152 self.error(format!("imap: {err:#}"));
1153 }
1154 self.check_local_mail();
1155 }
1156 Pending::Sync { purge } => match done {
1157 Err(err) => self.error(format!("sync failed: {err:#}")),
1159 Ok(_) => self.finish_sync(purge),
1160 },
1161 Pending::Counts(specs) => {
1162 if let Ok(Done::Counts(counts)) = done {
1163 for (spec, count) in specs.into_iter().zip(counts) {
1164 self.unseen.insert(spec, count);
1165 }
1166 self.requests.push(Request::MailboxesChanged);
1167 }
1168 }
1169 Pending::Again(again) => match done {
1170 Err(err) => self.error(format!("cannot fetch message: {err:#}")),
1171 Ok(_) => self.run_again(again),
1172 },
1173 }
1174 }
1175
1176 fn run_again(&mut self, again: Again) {
1179 match again {
1180 Again::View => self.open_message(),
1181 Again::Copy {
1182 input,
1183 delete,
1184 tagged,
1185 decode,
1186 } => self.copy_message(&input, delete, tagged, decode),
1187 Again::Pipe { command, tagged } => self.pipe_message(&command, tagged),
1188 Again::Print { tagged } => self.print_current(tagged),
1189 Again::Bounce { to, tagged } => self.bounce_current(&to, tagged),
1190 }
1191 }
1192
1193 fn have_bodies(&mut self, paths: &[PathBuf], again: Again) -> bool {
1200 if self.imap.is_none() {
1201 return true;
1202 }
1203 let missing: Vec<PathBuf> = paths
1204 .iter()
1205 .filter(|path| remote::is_partial(path))
1206 .cloned()
1207 .collect();
1208 if missing.is_empty() {
1209 return true;
1210 }
1211 self.settle();
1212 !self.start(Job::FetchBodies(missing), Pending::Again(again))
1213 }
1214
1215 fn target_paths(&self, tagged: bool) -> Vec<PathBuf> {
1218 self.op_targets(tagged)
1219 .into_iter()
1220 .map(|i| self.msgs[i].env.file.path.clone())
1221 .collect()
1222 }
1223
1224 pub fn idle_kick(&self) -> bool {
1227 self.idle.as_ref().is_some_and(|w| w.take_changed())
1228 }
1229
1230 fn check_other_mailboxes(&mut self) {
1233 let mut grew: Vec<String> = Vec::new();
1234 for spec in self.config.mail.mailboxes.clone() {
1235 if spec.starts_with("imap:") || spec == self.title {
1236 continue; }
1238 let dir = expand_tilde(&spec);
1239 if dir == self.dir || !dir.join("new").is_dir() {
1240 continue;
1241 }
1242 let count = maildir::new_count(&dir);
1243 let prev = self.mailbox_new.insert(spec.clone(), count);
1244 if let Some(p) = prev.filter(|&p| count > p) {
1245 self.run_new_mail_command(&spec, count - p);
1246 grew.push(spec);
1247 }
1248 }
1249 if !grew.is_empty() && self.notice().is_none() {
1251 self.notify(Notice::NewMail(format!("new mail in {}", grew.join(", "))));
1252 }
1253 }
1254
1255 pub fn maybe_backfill(&mut self) {
1258 if self.backfill.as_ref().is_some_and(|b| !b.done()) {
1259 return;
1260 }
1261 self.backfill = None;
1262 let Some(imap) = &mut self.imap else {
1263 return;
1264 };
1265 if imap.facts.pending_backfill.is_empty() {
1266 return;
1267 }
1268 let uids = imap.take_backfill();
1269 let facts = imap.facts.clone();
1270 let Ok(password) = account_password(&facts.account) else {
1271 return;
1272 };
1273 let count = uids.len();
1274 self.backfill = Some(remote::backfill(
1275 &facts.account,
1276 &facts.mailbox,
1277 &password,
1278 facts.cache,
1279 uids,
1280 ));
1281 self.note(format!(
1282 "loading {count} older message(s) in the background"
1283 ));
1284 }
1285
1286 pub fn each_tagged(&mut self, what: &str, f: impl Fn(&mut Msg)) {
1288 let tagged: Vec<usize> = (0..self.msgs.len())
1289 .filter(|&i| self.msgs[i].env.tagged)
1290 .collect();
1291 self.push_undo(what, &tagged);
1292 for &i in &tagged {
1293 let m = &mut self.msgs[i];
1294 f(m);
1295 m.dirty = true;
1296 }
1297 self.note(format!("applied to {} tagged message(s)", tagged.len()));
1298 }
1299
1300 pub fn op_targets(&self, tagged: bool) -> Vec<usize> {
1308 if tagged {
1309 return (0..self.msgs.len())
1310 .filter(|&i| self.msgs[i].env.tagged)
1311 .collect();
1312 }
1313 self.visible.get(self.sel).copied().into_iter().collect()
1314 }
1315
1316 pub fn push_undo(&mut self, what: &str, indices: &[usize]) {
1319 if indices.is_empty() {
1320 return;
1321 }
1322 let marks = indices.iter().map(|&i| self.mark(i)).collect();
1323 self.push_undo_step(UndoStep {
1324 what: what.to_string(),
1325 marks,
1326 sel: self.selected_path(),
1327 created: Vec::new(),
1328 note: None,
1329 rewritten: Vec::new(),
1330 });
1331 }
1332
1333 pub fn push_undo_step(&mut self, step: UndoStep) {
1334 self.undo.push(step);
1335 while self.undo.len() > UNDO_MAX_STEPS
1337 || (self.undo.len() > 1
1338 && self.undo.iter().map(|s| s.marks.len()).sum::<usize>() > UNDO_MAX_MARKS)
1339 {
1340 self.undo.remove(0);
1341 }
1342 }
1343
1344 pub fn mark(&self, i: usize) -> MsgMark {
1345 let m = &self.msgs[i];
1346 MsgMark {
1347 path: m.env.file.path.clone(),
1348 flags: m.env.file.flags,
1349 is_new: m.env.file.is_new,
1350 tagged: m.env.tagged,
1351 dirty: m.dirty,
1352 }
1353 }
1354
1355 pub fn undo_last(&mut self) {
1361 let Some(step) = self.undo.pop() else {
1362 self.note("nothing to undo");
1363 return;
1364 };
1365 let mut restored = 0usize;
1366 let mut rewrite_failed = Vec::new();
1367 let mut resort = false;
1368 for (path, bytes) in &step.rewritten {
1369 let at = self.msgs.iter().position(|m| &m.env.file.path == path);
1370 let put_back = std::fs::write(path, bytes)
1371 .map_err(|err| anyhow::anyhow!(err))
1372 .and_then(|()| match at {
1373 Some(i) => self.reread(i, bytes.len() as u64),
1374 None => Ok(()),
1375 });
1376 match put_back {
1377 Ok(()) => resort = true,
1378 Err(err) => rewrite_failed.push(format!("{}: {err}", path.display())),
1379 }
1380 }
1381 if resort {
1382 let keep = step.sel.clone();
1383 self.resort(keep);
1384 }
1385 let by_path: HashMap<&Path, usize> = self
1386 .msgs
1387 .iter()
1388 .enumerate()
1389 .map(|(i, m)| (m.env.file.path.as_path(), i))
1390 .collect();
1391 let mut wanted: Vec<(usize, MsgMark)> = Vec::new();
1392 for mark in &step.marks {
1393 if let Some(&i) = by_path.get(mark.path.as_path()) {
1394 wanted.push((i, mark.clone()));
1395 }
1396 }
1397 for (i, mark) in wanted {
1398 let m = &mut self.msgs[i];
1399 m.env.file.flags = mark.flags;
1400 m.env.file.is_new = mark.is_new;
1401 m.env.tagged = mark.tagged;
1402 m.dirty = mark.dirty;
1403 restored += 1;
1404 }
1405 let mut failed = rewrite_failed;
1406 for path in &step.created {
1407 if let Err(err) = std::fs::remove_file(path) {
1408 failed.push(format!("{}: {err}", path.display()));
1409 }
1410 }
1411 if let Some(vi) = step.sel.and_then(|p| {
1412 self.visible
1413 .iter()
1414 .position(|&i| self.msgs[i].env.file.path == p)
1415 }) {
1416 self.sel = vi;
1417 }
1418 let mut note = format!("undone: {} ({restored} message(s))", step.what);
1419 if let Some(extra) = &step.note {
1420 note += &format!("; {extra}");
1421 }
1422 if !failed.is_empty() {
1423 self.error(format!("{note}; could not remove {}", failed.join("; ")));
1424 } else {
1425 self.note(note);
1426 }
1427 }
1428
1429 pub fn message_bytes(&mut self, i: usize) -> Option<Vec<u8>> {
1432 let path = self.msgs.get(i)?.env.file.path.clone();
1433 if remote::is_partial(&path)
1434 && let Some(imap) = &mut self.imap
1435 && let Err(err) = imap.blocking(Job::FetchBodies(vec![path.clone()]))
1436 {
1437 self.error(format!("cannot fetch message: {err:#}"));
1438 return None;
1439 }
1440 match std::fs::read(&path) {
1441 Ok(bytes) => Some(bytes),
1442 Err(err) => {
1443 self.error(format!("cannot read message: {err}"));
1444 None
1445 }
1446 }
1447 }
1448
1449 pub fn full_message_bytes(&mut self) -> Option<Vec<u8>> {
1450 let &i = self.visible.get(self.sel)?;
1451 self.message_bytes(i)
1452 }
1453
1454 pub fn op_bytes(&mut self, tagged: bool) -> Option<Vec<u8>> {
1456 let mut out = Vec::new();
1457 for i in self.op_targets(tagged) {
1458 let bytes = self.message_bytes(i)?;
1459 out.extend_from_slice(&bytes);
1460 if !bytes.ends_with(b"\n") {
1461 out.push(b'\n');
1462 }
1463 }
1464 Some(out)
1465 }
1466
1467 pub fn rebuild_visible(&mut self, keep: Option<PathBuf>) {
1470 let mut visible = Vec::with_capacity(self.msgs.len());
1471 let positions = self.positions();
1472 for (i, pos) in positions.iter().enumerate() {
1473 let limit_ok = match &self.limit {
1474 Some((_, patterns)) => self.env_matches_at(patterns, &self.msgs[i].env, *pos, i),
1475 None => true,
1476 };
1477 if !limit_ok {
1478 continue;
1479 }
1480 if self.limit.is_none()
1481 && self.sort == SortKey::Threads
1482 && self.thread_depth.get(i).copied().unwrap_or(0) > 0
1483 {
1484 let root = self.thread_root.get(i).copied().unwrap_or(i);
1485 if self.collapsed.contains(&self.msgs[root].env.file.path) {
1486 continue;
1487 }
1488 }
1489 visible.push(i);
1490 }
1491 self.visible = visible;
1492 self.sel = keep
1493 .and_then(|p| {
1494 self.visible
1495 .iter()
1496 .position(|&i| self.msgs[i].env.file.path == p)
1497 })
1498 .unwrap_or_else(|| self.visible.len().saturating_sub(1));
1499 }
1500
1501 pub fn apply_sort(&mut self) {
1502 let keep = self.selected_path();
1503 self.resort(keep);
1504 }
1505
1506 fn resort(&mut self, keep: Option<PathBuf>) {
1507 if self.sort == SortKey::Threads {
1508 let order = self
1511 .config
1512 .index
1513 .sort_aux
1514 .as_deref()
1515 .map(thread::ThreadOrder::parse)
1516 .unwrap_or_default();
1517 let items = {
1522 let fallback = (!self.config.index.strict_threads.unwrap_or(false)).then(|| {
1523 thread::SubjectFallback {
1524 reply_re: &self.reply_re,
1525 sort_re: self.config.index.sort_re.unwrap_or(true),
1526 }
1527 });
1528 let envs: Vec<&Envelope> = self.msgs.iter().map(|m| &m.env).collect();
1529 thread::thread_with(&envs, order, fallback.as_ref())
1530 };
1531 let mut old: Vec<Option<Msg>> = self.msgs.drain(..).map(Some).collect();
1532 let mut new_pos = vec![0usize; old.len()];
1533 for (pos, item) in items.iter().enumerate() {
1534 new_pos[item.index] = pos;
1535 }
1536 self.msgs = items
1537 .iter()
1538 .map(|item| {
1539 old[item.index]
1540 .take()
1541 .expect("thread order is a permutation")
1542 })
1543 .collect();
1544 self.thread_depth = items.iter().map(|item| item.depth).collect();
1545 self.thread_root = items.iter().map(|item| new_pos[item.root]).collect();
1546 self.thread_pseudo = items.iter().map(|item| item.pseudo).collect();
1547 self.sort_rev = false;
1548 self.index_threads();
1549 } else {
1550 let (sort, rev) = (self.sort, self.sort_rev);
1551 self.msgs.sort_by(|a, b| {
1552 let ord = match sort {
1553 SortKey::Date => a.env.date.cmp(&b.env.date),
1554 SortKey::From => a.env.from.to_lowercase().cmp(&b.env.from.to_lowercase()),
1555 SortKey::Subject => {
1556 subject_key(&a.env.subject).cmp(&subject_key(&b.env.subject))
1557 }
1558 SortKey::Size => a.env.file.size.cmp(&b.env.file.size),
1559 SortKey::Label => {
1560 let key = |e: &Envelope| {
1562 let l = e.label.clone().unwrap_or_default().to_lowercase();
1563 (l.is_empty(), l)
1564 };
1565 key(&a.env).cmp(&key(&b.env))
1566 }
1567 SortKey::To => {
1568 let first_to =
1569 |e: &Envelope| e.to.first().cloned().unwrap_or_default().to_lowercase();
1570 first_to(&a.env).cmp(&first_to(&b.env))
1571 }
1572 SortKey::Unsorted => a
1573 .env
1574 .file
1575 .path
1576 .file_name()
1577 .cmp(&b.env.file.path.file_name()),
1578 SortKey::Threads => unreachable!(),
1579 };
1580 if rev { ord.reverse() } else { ord }
1581 });
1582 self.thread_depth = vec![0; self.msgs.len()];
1583 self.thread_root = (0..self.msgs.len()).collect();
1584 self.thread_pseudo = vec![false; self.msgs.len()];
1585 self.index_threads();
1586 }
1587 self.rebuild_visible(keep);
1588 }
1589
1590 fn index_threads(&mut self) {
1593 let n = self.msgs.len();
1594 let mut parent = vec![None; n];
1595 let mut children = vec![Vec::new(); n];
1596 let mut members: Vec<Vec<usize>> = vec![Vec::new(); n];
1597 let mut last_at_depth: Vec<usize> = Vec::new();
1598 #[allow(clippy::needless_range_loop)]
1599 for i in 0..n {
1600 let depth = self.thread_depth.get(i).copied().unwrap_or(0);
1601 last_at_depth.truncate(depth);
1602 if let Some(&p) = last_at_depth.last() {
1603 parent[i] = Some(p);
1604 children[p].push(i);
1605 }
1606 last_at_depth.push(i);
1607 let root = self.thread_root.get(i).copied().unwrap_or(i);
1608 members[root].push(i);
1609 }
1610 self.thread_parent = parent;
1611 self.thread_children = children;
1612 self.thread_members = members;
1613 }
1614
1615 pub fn toggle_collapse(&mut self, all: bool) {
1616 if self.sort != SortKey::Threads {
1617 self.error("folding needs thread sort (o t)");
1618 return;
1619 }
1620 let mut keep;
1621 if all {
1622 keep = self.selected_path();
1623 if self.collapsed.is_empty() {
1624 let fold_unread = self.config.index.collapse_unread.unwrap_or(true);
1627 self.collapsed = (0..self.msgs.len())
1628 .filter(|&i| self.thread_depth.get(i) == Some(&0))
1629 .filter(|&i| fold_unread || !self.thread_has_unread(i))
1630 .map(|i| self.msgs[i].env.file.path.clone())
1631 .collect();
1632 } else {
1633 self.collapsed.clear();
1634 }
1635 } else {
1636 let Some(&mi) = self.visible.get(self.sel) else {
1637 return;
1638 };
1639 let root = self.thread_root.get(mi).copied().unwrap_or(mi);
1640 let path = self.msgs[root].env.file.path.clone();
1641 let unfolding = self.collapsed.remove(&path);
1642 if !unfolding {
1643 self.collapsed.insert(path.clone());
1644 }
1645 keep = Some(path);
1646 if unfolding && self.config.index.uncollapse_jump {
1648 keep = self.first_unread_in_thread(root).or(keep);
1649 }
1650 }
1651 self.rebuild_visible(keep);
1652 }
1653
1654 pub fn step_message(&self, forward: bool, skip_deleted: bool) -> Option<usize> {
1657 let mut pos = self.sel;
1658 loop {
1659 pos = if forward {
1660 pos + 1
1661 } else {
1662 pos.checked_sub(1)?
1663 };
1664 let &i = self.visible.get(pos)?;
1665 if !skip_deleted || !self.msgs[i].env.file.flags.deleted {
1666 return Some(pos);
1667 }
1668 }
1669 }
1670
1671 fn thread_targets(&mut self, sub: bool) -> Option<Vec<usize>> {
1679 if self.sort != SortKey::Threads {
1680 self.error("thread operations need thread sort (o t)");
1681 return None;
1682 }
1683 let &mi = self.visible.get(self.sel)?;
1684 if !sub {
1685 let root = self.thread_root.get(mi).copied().unwrap_or(mi);
1686 return Some(
1687 (0..self.msgs.len())
1688 .filter(|&i| self.thread_root.get(i).copied().unwrap_or(i) == root)
1689 .collect(),
1690 );
1691 }
1692 let depth = self.thread_depth.get(mi).copied().unwrap_or(0);
1695 let mut out = vec![mi];
1696 for i in (mi + 1)..self.msgs.len() {
1697 if self.thread_depth.get(i).copied().unwrap_or(0) <= depth {
1698 break;
1699 }
1700 out.push(i);
1701 }
1702 Some(out)
1703 }
1704
1705 pub fn thread_mark(&mut self, sub: bool, op: ThreadOp) {
1709 if op != ThreadOp::Tag && self.deny_readonly() {
1710 return;
1711 }
1712 let Some(&mi) = self.visible.get(self.sel) else {
1713 return;
1714 };
1715 let Some(targets) = self.thread_targets(sub) else {
1716 return;
1717 };
1718 let scope = if sub { "subthread" } else { "thread" };
1719 let (what, verb) = match op {
1720 ThreadOp::Delete => (format!("delete {scope}"), "deleted"),
1721 ThreadOp::Undelete => (format!("undelete {scope}"), "undeleted"),
1722 ThreadOp::Read => (format!("read {scope}"), "marked read"),
1723 ThreadOp::Tag if self.msgs[mi].env.tagged => (format!("untag {scope}"), "untagged"),
1726 ThreadOp::Tag => (format!("tag {scope}"), "tagged"),
1727 };
1728 self.push_undo(&what, &targets);
1729 let want_tag = !self.msgs[mi].env.tagged;
1730 for &i in &targets {
1731 match op {
1732 ThreadOp::Delete => {
1733 self.msgs[i].env.file.flags.deleted = true;
1734 self.msgs[i].dirty = true;
1735 }
1736 ThreadOp::Undelete => {
1737 self.msgs[i].env.file.flags.deleted = false;
1738 self.msgs[i].dirty = true;
1739 }
1740 ThreadOp::Tag => self.msgs[i].env.tagged = want_tag,
1741 ThreadOp::Read => {
1742 let m = &mut self.msgs[i];
1743 if !m.env.file.flags.seen || m.env.file.is_new {
1744 m.env.file.flags.seen = true;
1745 m.env.file.is_new = false;
1746 m.dirty = true;
1747 }
1748 }
1749 }
1750 }
1751 self.note(format!("{} {verb}", targets.len()));
1752 if op == ThreadOp::Delete
1755 && let Some(pos) = self.step_message(true, true)
1756 {
1757 self.sel = pos;
1758 }
1759 }
1760
1761 pub fn jump_parent(&mut self, root: bool) {
1765 if self.sort != SortKey::Threads {
1766 self.error("thread operations need thread sort (o t)");
1767 return;
1768 }
1769 let Some(&mi) = self.visible.get(self.sel) else {
1770 return;
1771 };
1772 let target = if root {
1773 self.thread_root.get(mi).copied().filter(|&r| r != mi)
1774 } else {
1775 self.thread_parent.get(mi).copied().flatten()
1776 };
1777 let Some(target) = target else {
1778 self.error(if root {
1779 "already the thread root"
1780 } else {
1781 "no parent message"
1782 });
1783 return;
1784 };
1785 let tr = self.thread_root.get(target).copied().unwrap_or(target);
1786 let at = self
1787 .visible
1788 .iter()
1789 .position(|&i| i == target)
1790 .or_else(|| self.visible.iter().position(|&i| i == tr));
1791 match at {
1792 Some(vi) => self.select(vi),
1793 None => self.error("the parent is not in the current limit"),
1794 }
1795 }
1796
1797 fn can_rewrite(&mut self) -> bool {
1802 if self.deny_readonly() {
1803 return false;
1804 }
1805 if self.sort != SortKey::Threads {
1806 self.error("thread operations need thread sort (o t)");
1807 return false;
1808 }
1809 if self.imap.is_some() || self.mbox.is_some() {
1810 self.error("rewriting a message's threading is for local maildirs only");
1811 return false;
1812 }
1813 true
1814 }
1815
1816 fn rewrite_thread(
1822 &mut self,
1823 i: usize,
1824 in_reply_to: Option<&str>,
1825 references: &[String],
1826 broken: bool,
1827 ) -> Result<Vec<u8>> {
1828 let path = self.msgs[i].env.file.path.clone();
1829 let old = std::fs::read(&path).with_context(|| format!("reading {}", path.display()))?;
1830 let new = message::with_thread_headers(&old, in_reply_to, references, broken);
1831 std::fs::write(&path, &new).with_context(|| format!("writing {}", path.display()))?;
1832 self.reread(i, new.len() as u64)?;
1833 Ok(old)
1834 }
1835
1836 fn reread(&mut self, i: usize, size: u64) -> Result<()> {
1839 let mut file = self.msgs[i].env.file.clone();
1840 file.size = size;
1841 let tagged = self.msgs[i].env.tagged;
1842 let mut env = message::envelope(file)?;
1843 env.tagged = tagged;
1844 self.msgs[i].env = env;
1845 Ok(())
1846 }
1847
1848 pub fn break_thread(&mut self) {
1853 let Some(&mi) = self.visible.get(self.sel) else {
1854 return;
1855 };
1856 if !self.can_rewrite() {
1857 return;
1858 }
1859 if self.msgs[mi].env.references.is_empty() && !self.subject_threaded(mi) {
1863 self.note("already a thread of its own");
1864 return;
1865 }
1866 let path = self.msgs[mi].env.file.path.clone();
1867 let mut step = UndoStep {
1868 what: "break thread".into(),
1869 marks: vec![self.mark(mi)],
1870 sel: Some(path.clone()),
1871 created: Vec::new(),
1872 note: None,
1873 rewritten: Vec::new(),
1874 };
1875 match self.rewrite_thread(mi, None, &[], true) {
1876 Ok(old) => {
1877 step.rewritten.push((path.clone(), old));
1878 self.push_undo_step(step);
1879 self.resort(Some(path));
1880 self.note("thread broken");
1881 }
1882 Err(err) => self.error(format!("break thread: {err:#}")),
1883 }
1884 }
1885
1886 pub fn link_threads(&mut self) {
1891 let Some(&mi) = self.visible.get(self.sel) else {
1892 return;
1893 };
1894 if !self.can_rewrite() {
1895 return;
1896 }
1897 let Some(parent_id) = self.msgs[mi].env.msg_id.clone() else {
1898 self.error("no Message-ID to link to");
1899 return;
1900 };
1901 let kids: Vec<usize> = (0..self.msgs.len())
1902 .filter(|&i| i != mi && self.msgs[i].env.tagged)
1903 .collect();
1904 if kids.is_empty() {
1905 self.error("first tag the message(s) to link");
1906 return;
1907 }
1908 let keep = self.msgs[mi].env.file.path.clone();
1909 let mut step = UndoStep {
1910 what: "link threads".into(),
1911 marks: kids.iter().map(|&i| self.mark(i)).collect(),
1912 sel: Some(keep.clone()),
1913 created: Vec::new(),
1914 note: None,
1915 rewritten: Vec::new(),
1916 };
1917 let mut linked = 0usize;
1918 for &i in &kids {
1919 let path = self.msgs[i].env.file.path.clone();
1920 match self.rewrite_thread(i, Some(&parent_id), &[], false) {
1921 Ok(old) => {
1922 step.rewritten.push((path, old));
1923 self.msgs[i].env.tagged = false;
1924 linked += 1;
1925 }
1926 Err(err) => self.error(format!("link threads: {err:#}")),
1927 }
1928 }
1929 if linked > 0 {
1930 self.push_undo_step(step);
1931 self.resort(Some(keep));
1932 self.note(format!("{linked} linked"));
1933 }
1934 }
1935
1936 pub fn edit_label(&mut self, value: &str, tagged: bool) {
1941 if !self.can_rewrite_here() {
1942 return;
1943 }
1944 let targets = self.op_targets(tagged);
1945 if targets.is_empty() {
1946 return;
1947 }
1948 let value = value.trim();
1949 let new = (!value.is_empty()).then(|| value.to_string());
1950 let keep = self.selected_path();
1951 let mut step = UndoStep {
1952 what: "edit label".into(),
1953 marks: targets.iter().map(|&i| self.mark(i)).collect(),
1954 sel: keep.clone(),
1955 created: Vec::new(),
1956 note: None,
1957 rewritten: Vec::new(),
1958 };
1959 let mut done = 0usize;
1960 for &i in &targets {
1961 let path = self.msgs[i].env.file.path.clone();
1962 let old = match std::fs::read(&path) {
1963 Ok(b) => b,
1964 Err(err) => {
1965 self.error(format!("edit label: {err}"));
1966 continue;
1967 }
1968 };
1969 let bytes = message::with_header(&old, "X-Label", new.as_deref());
1970 if let Err(err) = std::fs::write(&path, &bytes) {
1971 self.error(format!("edit label: {err}"));
1972 continue;
1973 }
1974 step.rewritten.push((path, old));
1975 let _ = self.reread(i, bytes.len() as u64);
1976 done += 1;
1977 }
1978 if done > 0 {
1979 self.push_undo_step(step);
1980 self.resort(keep);
1981 self.note(match new {
1982 Some(_) => format!("labelled {done} message(s)"),
1983 None => format!("label cleared on {done} message(s)"),
1984 });
1985 }
1986 }
1987
1988 fn can_rewrite_here(&mut self) -> bool {
1991 if self.deny_readonly() {
1992 return false;
1993 }
1994 if self.imap.is_some() || self.mbox.is_some() {
1995 self.error("editing a label is for local maildirs only");
1996 return false;
1997 }
1998 true
1999 }
2000
2001 pub fn subject_threaded(&self, mi: usize) -> bool {
2005 self.thread_pseudo.get(mi).copied().unwrap_or(false)
2006 }
2007
2008 pub fn subject_hidden(&self, mi: usize) -> bool {
2013 if !self.config.index.hide_thread_subject.unwrap_or(false) {
2014 return false;
2015 }
2016 let Some(parent) = self.thread_parent.get(mi).copied().flatten() else {
2017 return false;
2018 };
2019 match (self.msgs.get(mi), self.msgs.get(parent)) {
2020 (Some(m), Some(p)) => subject_key(&m.env.subject) == subject_key(&p.env.subject),
2021 _ => false,
2022 }
2023 }
2024
2025 pub fn toggle_write(&mut self) {
2029 if self.read_only_session {
2030 self.error("mailbox is read-only for the whole session (-R)");
2031 return;
2032 }
2033 self.read_only = !self.read_only;
2034 if self.read_only {
2035 self.note("mailbox marked read-only");
2036 } else {
2037 self.note("mailbox marked writable");
2038 }
2039 }
2040
2041 pub fn show_limit(&mut self) {
2044 match &self.limit {
2045 Some((raw, _)) => self.note(format!("limit: {raw}")),
2046 None => self.note("no limit pattern (all messages shown)"),
2047 }
2048 }
2049
2050 pub fn reply_subject(&self, orig: &str) -> String {
2052 compose::reply_subject(orig, &self.reply_re)
2053 }
2054
2055 pub fn jump_thread(&mut self, forward: bool) {
2058 if self.sort != SortKey::Threads {
2059 self.error("thread operations need thread sort (o t)");
2060 return;
2061 }
2062 for (vi, wrapped) in wrap_order(self.visible.len(), self.sel, forward) {
2063 let mi = self.visible[vi];
2064 if self.thread_depth.get(mi).copied().unwrap_or(0) == 0 {
2065 if wrapped {
2066 self.note("wrapped around");
2067 }
2068 self.select(vi);
2069 return;
2070 }
2071 }
2072 self.error("no other thread");
2073 }
2074
2075 pub fn jump_new(&mut self, forward: bool) {
2076 let n = self.visible.len();
2077 if n == 0 {
2078 return;
2079 }
2080 for (vi, wrapped) in wrap_order(n, self.sel, forward) {
2081 let m = &self.msgs[self.visible[vi]];
2082 if m.env.file.is_new || !m.env.file.flags.seen {
2083 if wrapped {
2084 self.note("search wrapped");
2085 }
2086 self.select(vi);
2087 return;
2088 }
2089 }
2090 self.error("no new or unread messages");
2091 }
2092
2093 pub fn compile_search(&self, input: &str) -> Result<Vec<Pattern>, String> {
2101 pattern::parse(&self.expand_simple(input))
2102 }
2103
2104 fn expand_simple(&self, input: &str) -> String {
2105 let word = input.trim();
2106 if word.contains('~') || word.split_whitespace().count() != 1 {
2107 return input.to_string();
2108 }
2109 let template = self
2110 .config
2111 .mail
2112 .simple_search
2113 .as_deref()
2114 .unwrap_or("~f %s | ~s %s");
2115 template.replace("%s", word)
2116 }
2117
2118 pub fn apply_pattern(&mut self, input: &str, verb: &'static str, f: impl Fn(&mut Msg)) {
2119 if input.is_empty() {
2120 return;
2121 }
2122 let patterns = match self.compile_search(input) {
2123 Ok(p) => p,
2124 Err(err) => {
2125 self.error(format!("bad pattern: {err}"));
2126 return;
2127 }
2128 };
2129 self.resolve_body_terms(&patterns);
2130 let positions = self.positions();
2131 let mut hits = Vec::new();
2132 for (i, pos) in positions.iter().enumerate() {
2133 let in_limit = match &self.limit {
2134 Some((_, l)) => self.env_matches_at(l, &self.msgs[i].env, *pos, i),
2135 None => true,
2136 };
2137 if in_limit && self.env_matches_at(&patterns, &self.msgs[i].env, *pos, i) {
2138 hits.push(i);
2139 }
2140 }
2141 self.push_undo(&format!("{verb} by pattern"), &hits);
2142 for &i in &hits {
2143 f(&mut self.msgs[i]);
2144 }
2145 self.note(format!("{} {verb}", hits.len()));
2146 }
2147
2148 pub fn search_next(&mut self) {
2149 let Some(patterns) = self.last_search.clone() else {
2150 self.error("no search pattern (use /)");
2151 return;
2152 };
2153 if self.visible.is_empty() {
2154 return;
2155 }
2156 let wrap = self.config.mail.wrap_search.unwrap_or(true);
2159 let positions = self.positions();
2160 for (vi, wrapped) in wrap_order(self.visible.len(), self.sel, !self.search_rev) {
2161 if wrapped && !wrap {
2162 break;
2163 }
2164 let mi = self.visible[vi];
2165 if self.env_matches_at(&patterns, &self.msgs[mi].env, positions[mi], mi) {
2166 if wrapped {
2167 self.note("search wrapped");
2168 }
2169 self.sel = vi;
2170 return;
2171 }
2172 }
2173 self.error("not found");
2174 }
2175
2176 fn trash_deleted(&mut self, trash: &str) -> Result<()> {
2180 let deleted: Vec<PathBuf> = self
2181 .msgs
2182 .iter()
2183 .filter(|m| m.env.file.flags.deleted)
2184 .map(|m| m.env.file.path.clone())
2185 .collect();
2186 match (remote::parse_spec(trash), &mut self.imap) {
2187 (Some((account, folder)), Some(imap)) if imap.facts.account.name == account => imap
2188 .blocking(Job::CopyToFolder {
2189 paths: deleted,
2190 mailbox: folder.to_string(),
2191 })
2192 .map(|_| ()),
2193 (Some(_), _) => anyhow::bail!("trash must be a folder of the open account"),
2194 (None, Some(_)) => {
2195 anyhow::bail!("an IMAP mailbox needs an imap:account/folder trash")
2196 }
2197 (None, None) => {
2198 let dir = expand_tilde(trash);
2199 maildir::create(&dir)?;
2200 for m in self.msgs.iter().filter(|m| m.env.file.flags.deleted) {
2201 let bytes = std::fs::read(&m.env.file.path)?;
2202 let mut flags = m.env.file.flags;
2203 flags.deleted = false;
2204 maildir::deliver(&dir, &bytes, flags)?;
2205 }
2206 Ok(())
2207 }
2208 }
2209 }
2210
2211 pub fn sync(&mut self, purge: bool) {
2214 if self.deny_readonly() {
2215 return;
2216 }
2217 if purge
2220 && self.deleted_count() > 0
2221 && let Some(trash) = self.config.mail.trash.clone()
2222 && trash != self.title
2223 && expand_tilde(&trash) != self.dir
2224 && let Err(err) = self.trash_deleted(&trash)
2225 {
2226 self.error(format!("trash failed: {err:#}; nothing purged"));
2227 return;
2228 }
2229 if self.imap.is_some() {
2230 let mut deletes: Vec<PathBuf> = Vec::new();
2231 let mut flags: Vec<(PathBuf, maildir::Flags)> = Vec::new();
2232 for m in &self.msgs {
2233 if m.env.file.flags.deleted {
2234 if purge {
2235 deletes.push(m.env.file.path.clone());
2236 }
2237 } else if m.dirty {
2238 flags.push((m.env.file.path.clone(), m.env.file.flags));
2239 }
2240 }
2241 if self.start(Job::Sync { flags, deletes }, Pending::Sync { purge }) {
2245 return;
2246 }
2247 }
2248 self.finish_sync(purge);
2249 }
2250
2251 fn finish_sync(&mut self, purge: bool) {
2254 if let Some(mbox) = &mut self.mbox {
2255 let mut state: HashMap<String, Option<(maildir::Flags, bool)>> = HashMap::new();
2258 for m in &self.msgs {
2259 let Some(id) = mbox::id_of(&m.env.file.path) else {
2260 continue;
2261 };
2262 if m.env.file.flags.deleted && purge {
2263 state.insert(id, None);
2264 } else if m.pending() {
2265 let is_new = m.env.file.is_new && !m.dirty;
2266 state.insert(id, Some((m.env.file.flags, is_new)));
2267 }
2268 }
2269 if let Err(err) = mbox.write_back(&state) {
2270 self.error(format!("sync failed: {err:#}"));
2272 return;
2273 }
2274 }
2275 let keep = self.selected_path();
2276 let mut removed = 0usize;
2277 let mut saved = 0usize;
2278 let mut errors: Vec<String> = Vec::new();
2279 self.msgs.retain_mut(|m| {
2280 if m.env.file.flags.deleted {
2281 if !purge {
2282 return true; }
2284 match maildir::remove(&m.env.file) {
2285 Ok(()) => {
2286 removed += 1;
2287 false
2288 }
2289 Err(err) => {
2290 errors.push(err.to_string());
2291 true
2292 }
2293 }
2294 } else {
2295 if m.dirty {
2296 match maildir::store_flags(&m.env.file) {
2297 Ok(path) => {
2298 m.env.file.path = path;
2299 m.env.file.is_new = false;
2300 m.dirty = false;
2301 saved += 1;
2302 }
2303 Err(err) => errors.push(err.to_string()),
2304 }
2305 }
2306 true
2307 }
2308 });
2309 self.dir_mtimes = dir_mtimes(&self.dir);
2310 self.undo.clear();
2313 self.resort(keep);
2314 match errors.is_empty() {
2315 true => self.notify(Notice::Synced {
2316 deleted: removed,
2317 updated: saved,
2318 }),
2319 false => self.note(format!("sync errors: {}", errors.join("; "))),
2320 }
2321 }
2322
2323 pub fn create_folder(&mut self, spec: &str) -> Result<String, String> {
2334 let id = self.manage(
2335 spec,
2336 |_account, folder| Manage::Create(folder),
2337 |path| {
2338 maildir::create(&path).map_err(|e| format!("{e:#}"))?;
2339 Ok(path.display().to_string())
2340 },
2341 )?;
2342 Ok(format!("created {id}"))
2343 }
2344
2345 pub fn delete_folder(&mut self, spec: &str) -> Result<String, String> {
2346 let id = self.manage(
2347 spec,
2348 |_account, folder| Manage::Delete(folder),
2349 |path| {
2350 std::fs::remove_dir_all(&path).map_err(|e| format!("{e}"))?;
2353 Ok(path.display().to_string())
2354 },
2355 )?;
2356 Ok(format!("deleted {id}"))
2357 }
2358
2359 pub fn set_subscribed(&mut self, spec: &str, on: bool) -> Result<String, String> {
2360 let verb = if on {
2361 "subscribed to"
2362 } else {
2363 "unsubscribed from"
2364 };
2365 self.manage(
2366 spec,
2367 move |_account, folder| Manage::Subscribe(folder, on),
2368 |_path| Err("subscription is an IMAP notion".into()),
2369 )
2370 .map(|shown| format!("{verb} {shown}"))
2371 }
2372
2373 pub fn rename_folder(&mut self, spec: &str, new: &str) -> Result<String, String> {
2376 if new.trim().is_empty() {
2377 return Err("no new name given".into());
2378 }
2379 match remote::parse_spec(spec) {
2380 Some((account, from)) => {
2381 let to = new.trim().to_string();
2382 self.on_account(account, Manage::Rename(from.to_string(), to.clone()))?;
2383 Ok(format!("renamed to {to}"))
2384 }
2385 None => {
2386 let from = expand_tilde(spec);
2387 let to = expand_tilde(new);
2388 std::fs::rename(&from, &to).map_err(|e| format!("{e}"))?;
2389 Ok(format!("renamed to {}", to.display()))
2390 }
2391 }
2392 }
2393
2394 fn manage(
2398 &mut self,
2399 spec: &str,
2400 make: impl FnOnce(&str, String) -> Manage,
2401 local: impl FnOnce(std::path::PathBuf) -> Result<String, String>,
2402 ) -> Result<String, String> {
2403 match remote::parse_spec(spec) {
2404 Some((account, folder)) => {
2405 let action = make(account, folder.to_string());
2406 self.on_account(account, action)?;
2407 Ok(format!("imap:{account}/{folder}"))
2408 }
2409 None => local(expand_tilde(spec)),
2410 }
2411 }
2412
2413 fn on_account(&mut self, account: &str, action: Manage) -> Result<(), String> {
2416 match &mut self.imap {
2417 Some(imap) if imap.facts.account.name == account => imap
2418 .blocking(Job::Manage(action))
2419 .map(drop)
2420 .map_err(|e| format!("{e:#}")),
2421 Some(_) | None => Err("that mailbox is not on the open account".into()),
2422 }
2423 }
2424
2425 pub fn folder_candidates(&mut self) -> Result<Vec<(String, usize)>> {
2426 let mut dirs: Vec<(String, usize)> = self
2429 .config
2430 .mail
2431 .mailboxes
2432 .iter()
2433 .filter(|m| m.starts_with("imap:") || expand_tilde(m).join("cur").is_dir())
2434 .map(|m| {
2435 let count = if m.starts_with("imap:") {
2436 0
2437 } else {
2438 maildir::new_count(&expand_tilde(m))
2439 };
2440 (m.clone(), count)
2441 })
2442 .collect();
2443 match &mut self.imap {
2444 Some(imap) => {
2445 let account = imap.facts.account.name.clone();
2446 if let Done::Folders(folders) = imap.blocking(Job::Folders)? {
2447 dirs.extend(
2448 folders
2449 .into_iter()
2450 .map(|(f, unseen)| (format!("imap:{account}/{f}"), unseen)),
2451 );
2452 }
2453 }
2454 None => dirs.extend(
2455 maildir::discover(&self.dir)
2456 .iter()
2457 .map(|p| (p.display().to_string(), maildir::new_count(p))),
2458 ),
2459 }
2460 dirs.sort();
2461 dirs.dedup_by(|a, b| {
2462 if a.0 == b.0 {
2463 b.1 = b.1.max(a.1);
2464 true
2465 } else {
2466 false
2467 }
2468 });
2469 Ok(dirs)
2470 }
2471
2472 pub fn unseen_count(&self, spec: &str) -> usize {
2481 match remote::parse_spec(spec) {
2482 Some(_) => self.unseen.get(spec).copied().unwrap_or(0),
2483 None => maildir::new_count(&expand_tilde(spec)),
2484 }
2485 }
2486
2487 fn refresh_unseen(&mut self) -> bool {
2490 let Some(imap) = &self.imap else {
2491 return false;
2492 };
2493 let account = imap.facts.account.name.clone();
2494 let wanted: Vec<(String, String)> = self
2495 .config
2496 .mail
2497 .mailboxes
2498 .iter()
2499 .filter_map(|spec| match remote::parse_spec(spec) {
2500 Some((a, folder)) if a == account => Some((spec.clone(), folder.to_string())),
2501 _ => None,
2502 })
2503 .collect();
2504 if wanted.is_empty() {
2505 return false;
2506 }
2507 let (specs, folders): (Vec<String>, Vec<String>) = wanted.into_iter().unzip();
2508 self.start(Job::Unseen(folders), Pending::Counts(specs))
2509 }
2510
2511 pub fn store_edited(&mut self, path: &Path, edited: &[u8]) {
2516 match &mut self.imap {
2517 Some(imap) => {
2518 let Some(flags) = self
2519 .visible
2520 .get(self.sel)
2521 .map(|&i| self.msgs[i].env.file.flags)
2522 else {
2523 return;
2524 };
2525 let mailbox = imap.facts.mailbox.clone();
2526 if let Err(err) = imap.blocking(Job::Append {
2527 mailbox: Some(mailbox),
2528 flags,
2529 body: edited.to_vec(),
2530 }) {
2531 self.error(format!("cannot store the edited copy: {err:#}"));
2532 return;
2533 }
2534 if let Some(m) = self.cur_mut() {
2535 m.env.file.flags.deleted = true;
2536 m.dirty = true;
2537 }
2538 self.check_new_mail();
2539 self.note("edited copy appended; original marked deleted ($ purges)");
2540 }
2541 None => {
2542 if let Err(err) = std::fs::write(path, edited) {
2543 self.error(format!("cannot write message: {err}"));
2544 return;
2545 }
2546 self.rescan();
2547 self.note("message edited");
2548 }
2549 }
2550 }
2551
2552 pub fn mark_old_unread(&mut self) {
2556 if self.read_only || !self.config.mail.mark_old.unwrap_or(true) {
2557 return;
2558 }
2559 for m in &mut self.msgs {
2560 if m.env.file.is_new
2561 && !m.env.file.flags.seen
2562 && !m.env.file.flags.deleted
2563 && let Ok(path) = maildir::store_flags(&m.env.file)
2564 {
2565 m.env.file.path = path;
2566 m.env.file.is_new = false;
2567 }
2568 }
2569 }
2570
2571 pub fn ready_to_leave(&mut self) -> bool {
2575 if self.deleted_count() > 0 {
2576 self.error("deleted messages pending; sync with $ or undelete first");
2577 return false;
2578 }
2579 if self.pending_count() > 0 {
2580 self.sync(false);
2581 if self.pending_count() > 0 {
2582 return false; }
2584 }
2585 true
2586 }
2587
2588 pub fn smtp_account(&self) -> Option<Account> {
2592 if std::env::var("RMUT_SENDMAIL").is_ok() || self.config.mail.sendmail.is_some() {
2593 return None;
2594 }
2595 if let Some(imap) = &self.imap
2596 && imap.facts.account.smtp_host.is_some()
2597 {
2598 return Some(imap.facts.account.clone());
2599 }
2600 self.config
2601 .accounts
2602 .iter()
2603 .find(|a| a.smtp_host.is_some())
2604 .cloned()
2605 }
2606
2607 pub fn copy_message(&mut self, input: &str, delete: bool, tagged: bool, decode: bool) {
2611 if input.is_empty() {
2612 self.error("no mailbox given");
2613 return;
2614 }
2615 let targets = self.op_targets(tagged);
2616 if targets.is_empty() {
2617 return;
2618 }
2619 let paths = self.target_paths(tagged);
2621 if !self.have_bodies(
2622 &paths,
2623 Again::Copy {
2624 input: input.to_string(),
2625 delete,
2626 tagged,
2627 decode,
2628 },
2629 ) {
2630 return;
2631 }
2632 let mut created: Vec<PathBuf> = Vec::new();
2635 let mut marks = Vec::new();
2636 let mut copied = Vec::new();
2637 let mut errors: Vec<String> = Vec::new();
2638 let mut note = None;
2639 let mut target = String::new();
2640 for &i in &targets {
2641 match self.copy_one(i, input, decode, &mut created) {
2642 Ok(shown) => {
2643 if shown.starts_with("imap:") {
2644 note = Some(format!("the copy in {shown} stays"));
2645 }
2646 target = shown;
2647 marks.push(self.mark(i));
2648 copied.push(i);
2649 }
2650 Err(err) => errors.push(err),
2653 }
2654 }
2655 let verb = if delete { "save" } else { "copy" };
2656 if copied.is_empty() {
2657 self.error(format!("cannot {verb}: {}", errors.join("; ")));
2658 return;
2659 }
2660 self.push_undo_step(UndoStep {
2661 what: format!("{verb} to {target}"),
2662 marks,
2663 sel: self.selected_path(),
2664 created,
2665 note,
2666 rewritten: Vec::new(),
2667 });
2668 let n = copied.len();
2669 if delete {
2670 for &i in &copied {
2671 self.msgs[i].env.file.flags.deleted = true;
2672 self.msgs[i].dirty = true;
2673 }
2674 }
2675 let mut status = match (delete, n) {
2676 (true, 1) => format!("saved to {target} (original marked deleted)"),
2677 (true, _) => format!("saved {n} to {target} (originals marked deleted)"),
2678 (false, 1) => format!("copied to {target}"),
2679 (false, _) => format!("copied {n} to {target}"),
2680 };
2681 if !errors.is_empty() {
2682 status += &format!("; {} failed: {}", errors.len(), errors.join("; "));
2683 self.error(status);
2684 } else {
2685 self.note(status);
2686 }
2687 }
2688
2689 fn copy_one(
2693 &mut self,
2694 i: usize,
2695 spec: &str,
2696 decode: bool,
2697 created: &mut Vec<PathBuf>,
2698 ) -> Result<String, String> {
2699 let flags = self.msgs[i].env.file.flags;
2700 let bytes = if decode {
2704 self.displayed_text(i)?.into_bytes()
2705 } else {
2706 self.message_bytes(i).ok_or("cannot read the message")?
2707 };
2708 match remote::parse_spec(spec) {
2709 Some((account, folder)) => match &mut self.imap {
2710 Some(imap) if imap.facts.account.name == account => imap
2711 .blocking(Job::Append {
2712 mailbox: Some(folder.to_string()),
2713 flags,
2714 body: bytes,
2715 })
2716 .map(|done| match done {
2717 Done::Folder(folder) => format!("imap:{account}/{folder}"),
2718 _ => format!("imap:{account}/{folder}"),
2719 })
2720 .map_err(|err| format!("{err:#}")),
2721 _ => Err("can only save to a folder of the open account".into()),
2722 },
2723 None => {
2724 let dir = expand_tilde(spec);
2725 maildir::create(&dir)
2726 .and_then(|()| maildir::deliver(&dir, &bytes, flags))
2727 .map(|path| {
2728 created.push(path);
2729 dir.display().to_string()
2730 })
2731 .map_err(|err| format!("{err:#}"))
2732 }
2733 }
2734 }
2735
2736 pub fn create_alias(&mut self, nick: &str, addr: &str) {
2738 if nick.is_empty() || nick.contains(char::is_whitespace) {
2739 self.error("the alias nick must be one word");
2740 return;
2741 }
2742 match alias::append_to(self.config.mail.alias_file.as_deref(), nick, addr) {
2743 Ok(_) => self.note(format!("added: alias {nick} {addr}")),
2744 Err(err) => self.error(format!("cannot save the alias: {err:#}")),
2745 }
2746 }
2747
2748 pub fn pipe_message(&mut self, command: &str, tagged: bool) {
2752 if command.is_empty() {
2753 self.error("no command given");
2754 return;
2755 }
2756 let paths = self.target_paths(tagged);
2757 if !self.have_bodies(
2758 &paths,
2759 Again::Pipe {
2760 command: command.to_string(),
2761 tagged,
2762 },
2763 ) {
2764 return;
2765 }
2766 let targets = self.op_targets(tagged);
2767 let n = targets.len();
2768 let decode = self.config.mail.pipe_decode.unwrap_or(false);
2769 let split = self.config.mail.pipe_split.unwrap_or(false);
2770 let sep = self
2771 .config
2772 .mail
2773 .pipe_sep
2774 .clone()
2775 .unwrap_or_else(|| "\n".into());
2776 match self.run_over(&targets, command, decode, split, &sep) {
2777 Ok(()) => self.note(match n {
2778 1 => format!("piped to {command}"),
2779 _ => format!("piped {n} messages to {command}"),
2780 }),
2781 Err(err) => self.error(format!("pipe failed: {err}")),
2782 }
2783 }
2784
2785 pub fn bounce_current(&mut self, to: &str, tagged: bool) {
2788 let rcpts = compose::addresses(to);
2789 if rcpts.is_empty() {
2790 self.error(format!("cannot parse the addresses in {to:?}"));
2791 return;
2792 }
2793 let paths = self.target_paths(tagged);
2794 if !self.have_bodies(
2795 &paths,
2796 Again::Bounce {
2797 to: to.to_string(),
2798 tagged,
2799 },
2800 ) {
2801 return;
2802 }
2803 let targets = self.op_targets(tagged);
2804 let mut sent = 0usize;
2805 for i in targets {
2806 let Some(bytes) = self.message_bytes(i) else {
2807 return;
2808 };
2809 if let Err(err) = self.bounce_one(&bytes, to, &rcpts) {
2810 self.error(format!("bounce failed: {err:#}"));
2811 return;
2812 }
2813 sent += 1;
2814 }
2815 self.note(match sent {
2816 1 => format!("message bounced to {to}"),
2817 _ => format!("{sent} messages bounced to {to}"),
2818 });
2819 }
2820
2821 fn bounce_one(&mut self, bytes: &[u8], to: &str, rcpts: &[String]) -> Result<()> {
2824 let host = maildir::hostname();
2825 let from = self
2826 .current_identity(rcpts)
2827 .from_line()
2828 .unwrap_or_else(|| default_from(&host));
2829 let text = compose::bounce_text(
2830 bytes,
2831 &from,
2832 to,
2833 &compose::rfc2822_now(),
2834 &compose::make_message_id(&host),
2835 );
2836 let envelope_from = compose::bare_address(&from).unwrap_or_else(|| from.clone());
2837 match self.smtp_account() {
2838 Some(account) => account_password(&account).and_then(|password| {
2839 smtp::send(&account, &password, &envelope_from, rcpts, text.as_bytes())
2840 }),
2841 None => run_sendmail(
2842 text.as_bytes(),
2843 self.config.mail.sendmail.as_deref(),
2844 Some(rcpts),
2845 ),
2846 }
2847 }
2848
2849 pub fn open_message(&mut self) {
2852 let Some(path) = self.selected_path() else {
2853 return;
2854 };
2855 if !self.have_bodies(std::slice::from_ref(&path), Again::View) {
2856 return;
2857 }
2858 self.mark_read();
2859 match self.load_view(&path) {
2860 Ok(view) => self.requests.push(Request::ShowMessage(Box::new(view))),
2861 Err(err) => self.error(format!("cannot open message: {err:#}")),
2862 }
2863 }
2864
2865 pub fn load_view(&mut self, path: &Path) -> Result<message::MessageView> {
2868 if remote::is_partial(path)
2870 && let Some(imap) = &mut self.imap
2871 {
2872 imap.blocking(Job::FetchBodies(vec![path.to_path_buf()]))
2873 .context("cannot fetch message")?;
2874 if let Some(m) = self.msgs.iter_mut().find(|m| m.env.file.path == path)
2876 && let Ok(raw) = std::fs::read(path)
2877 {
2878 m.env.lines = Some(message::body_lines(&raw));
2879 }
2880 }
2881 let mut view = message::load_with(path, &self.display)?;
2882 if let Ok(raw) = std::fs::read(path)
2885 && let Some(p) = pgp::view(&self.config.pgp, &raw)
2886 {
2887 match p.body {
2888 Some(pgp::Body::Entity(raw)) => {
2892 view.body = message::render_entity(&raw, &self.display);
2893 }
2894 Some(pgp::Body::Text(text)) => view.body = text,
2895 None => {}
2896 }
2897 view.body = format!("{}\n\n{}", p.note, view.body);
2898 }
2899 Ok(view)
2900 }
2901
2902 fn displayed_text(&mut self, i: usize) -> Result<String, String> {
2906 let path = self.msgs[i].env.file.path.clone();
2907 let view = self
2908 .load_view(&path)
2909 .map_err(|err| format!("cannot decode: {err:#}"))?;
2910 let mut text = String::new();
2911 for (name, value) in &view.brief {
2912 text += &format!("{name}: {value}\n");
2913 }
2914 text.push('\n');
2915 text += &view.body;
2916 if !text.ends_with('\n') {
2917 text.push('\n');
2918 }
2919 Ok(text)
2920 }
2921
2922 pub fn print_current(&mut self, tagged: bool) {
2926 let paths = self.target_paths(tagged);
2927 if !self.have_bodies(&paths, Again::Print { tagged }) {
2928 return;
2929 }
2930 let targets = self.op_targets(tagged);
2931 if targets.is_empty() {
2932 return;
2933 }
2934 let command = self
2935 .config
2936 .mail
2937 .print
2938 .clone()
2939 .unwrap_or_else(|| "lpr".into());
2940 let decode = self.config.mail.print_decode.unwrap_or(true);
2941 let split = self.config.mail.print_split.unwrap_or(false);
2942 let n = targets.len();
2943 match self.run_over(&targets, &command, decode, split, "\n") {
2944 Ok(()) => self.note(match n {
2945 1 => format!("printed via {command}"),
2946 _ => format!("printed {n} messages via {command}"),
2947 }),
2948 Err(err) => self.error(format!("print failed: {err}")),
2949 }
2950 }
2951
2952 fn op_one_bytes(&mut self, i: usize, decode: bool) -> Result<Vec<u8>, String> {
2955 if decode {
2956 self.displayed_text(i).map(String::into_bytes)
2957 } else {
2958 self.message_bytes(i)
2959 .map(|mut b| {
2960 if !b.ends_with(b"\n") {
2961 b.push(b'\n');
2962 }
2963 b
2964 })
2965 .ok_or_else(|| "cannot read the message".into())
2966 }
2967 }
2968
2969 fn run_over(
2973 &mut self,
2974 targets: &[usize],
2975 command: &str,
2976 decode: bool,
2977 split: bool,
2978 sep: &str,
2979 ) -> Result<(), String> {
2980 if split {
2981 for &i in targets {
2982 let bytes = self.op_one_bytes(i, decode)?;
2983 pipe_to(command, &bytes).map_err(|err| format!("{err:#}"))?;
2984 }
2985 return Ok(());
2986 }
2987 let mut all: Vec<u8> = Vec::new();
2988 for (n, &i) in targets.iter().enumerate() {
2989 if n > 0 {
2990 all.extend_from_slice(sep.as_bytes());
2991 }
2992 all.extend_from_slice(&self.op_one_bytes(i, decode)?);
2993 }
2994 pipe_to(command, &all).map_err(|err| format!("{err:#}"))
2995 }
2996
2997 pub fn current_identity(&self, rcpts: &[String]) -> rmut_core::config::Identity {
3000 self.config.identity_for(
3001 &self.title,
3002 rcpts,
3003 self.imap.as_ref().map(|imap| &imap.facts.account),
3004 )
3005 }
3006
3007 pub fn matching_message_hooks(&self) -> Vec<usize> {
3009 let Some(env) = self.visible.get(self.sel).map(|&mi| &self.msgs[mi].env) else {
3010 return Vec::new();
3011 };
3012 let pos = pattern::Position {
3015 number: self.sel + 1,
3016 current: self.sel + 1,
3017 last: self.visible.len(),
3018 duplicate: false,
3019 };
3020 self.message_hooks
3021 .iter()
3022 .enumerate()
3023 .filter(|(_, h)| pattern::matches_in(&h.patterns, env, self.scope(pos), None))
3024 .map(|(i, _)| i)
3025 .collect()
3026 }
3027
3028 pub fn reply_hook_lines(&self, path: &Path) -> Vec<String> {
3032 if self.reply_hooks.is_empty() {
3033 return Vec::new();
3034 }
3035 let Some(env) = self.msgs.iter().find(|m| m.env.file.path == *path) else {
3036 return Vec::new();
3037 };
3038 let scope = self.scope(pattern::Position::default());
3039 self.reply_hooks
3040 .iter()
3041 .filter(|h| pattern::matches_in(&h.patterns, &env.env, scope, None))
3042 .map(|h| h.value.clone())
3043 .collect()
3044 }
3045
3046 pub fn fcc_hook_target(&self, draft: &str, path: &Path) -> Option<String> {
3049 if self.fcc_hooks.is_empty() {
3050 return None;
3051 }
3052 let env = compose::draft_envelope(draft, path);
3053 let scope = self.scope(pattern::Position::default());
3054 self.fcc_hooks
3055 .iter()
3056 .find(|h| pattern::matches_in(&h.patterns, &env, scope, None))
3057 .map(|h| h.value.clone())
3058 }
3059
3060 pub fn crypt_key_for(&self, address: &str) -> Option<String> {
3063 self.crypt_hooks
3064 .iter()
3065 .find(|(m, _)| m.is_match(address))
3066 .map(|(_, key)| key.clone())
3067 }
3068
3069 pub fn notmuch_mirror(&mut self, query: &str) -> Option<(PathBuf, usize)> {
3077 if query.is_empty() {
3078 return None;
3079 }
3080 let out = Command::new("notmuch")
3081 .args(["search", "--output=files", "--limit=1000", "--", query])
3082 .output();
3083 let out = match out {
3084 Ok(out) if out.status.success() => out,
3085 Ok(out) => {
3086 let err = String::from_utf8_lossy(&out.stderr);
3087 self.error(format!("notmuch: {}", err.trim()));
3088 return None;
3089 }
3090 Err(err) => {
3091 self.error(format!("notmuch: {err}"));
3092 return None;
3093 }
3094 };
3095 let stdout = String::from_utf8_lossy(&out.stdout);
3096 let files: Vec<&str> = stdout.lines().filter(|l| !l.trim().is_empty()).collect();
3097 if files.is_empty() {
3098 self.error("notmuch: no matches");
3099 return None;
3100 }
3101 if !self.ready_to_leave() {
3102 return None;
3103 }
3104 let dir = remote::cache_base().join("notmuch");
3105 let build = || -> Result<()> {
3106 for sub in ["cur", "new", "tmp"] {
3107 std::fs::create_dir_all(dir.join(sub))?;
3108 }
3109 for entry in std::fs::read_dir(dir.join("cur"))?.flatten() {
3110 let _ = std::fs::remove_file(entry.path());
3111 }
3112 for (i, file) in files.iter().enumerate() {
3113 let base = Path::new(file)
3114 .file_name()
3115 .map(|n| n.to_string_lossy().into_owned())
3116 .unwrap_or_else(|| format!("{i}"));
3117 let _ = std::os::unix::fs::symlink(
3120 file,
3121 dir.join("cur").join(format!("{i:04}.{base}")),
3122 );
3123 }
3124 Ok(())
3125 };
3126 if let Err(err) = build() {
3127 self.note(format!("notmuch mirror: {err:#}"));
3128 return None;
3129 }
3130 Some((dir, files.len()))
3131 }
3132
3133 pub fn compose_base(&self) -> Option<ComposeBase> {
3134 let &mi = self.visible.get(self.sel)?;
3135 let env = &self.msgs[mi].env;
3136 let view = message::load(&env.file.path).ok()?;
3137 let get = |name: &str| {
3138 view.all
3139 .iter()
3140 .filter(|(k, _)| k.eq_ignore_ascii_case(name))
3141 .map(|(_, v)| v.clone())
3142 .collect::<Vec<_>>()
3143 .join(", ")
3144 };
3145 let from_hdr = get("From");
3146 let (reply_to, has_reply_to) = {
3147 let rt = get("Reply-To");
3148 if rt.trim().is_empty() {
3149 (from_hdr.clone(), false)
3150 } else {
3151 let differs = rt.trim() != from_hdr.trim();
3152 (rt, differs)
3153 }
3154 };
3155 Some(ComposeBase {
3156 path: env.file.path.clone(),
3157 reply_to,
3158 from_hdr,
3159 has_reply_to,
3160 orig_to: get("To"),
3161 orig_cc: get("Cc"),
3162 list_post: compose::list_post_address(&get("List-Post")),
3163 followup_to: get("Mail-Followup-To"),
3164 from_addr: compose::addresses(&get("From"))
3165 .into_iter()
3166 .next()
3167 .unwrap_or_default(),
3168 from_display: env.from.clone(),
3169 subject: env.subject.clone(),
3170 date: env.date,
3171 msg_id: env.msg_id.clone(),
3172 references: env.references.clone(),
3173 })
3174 }
3175
3176 pub fn list_target(&self, base: &ComposeBase) -> Option<String> {
3182 if let Some(addr) = &base.list_post {
3183 return Some(addr.clone());
3184 }
3185 let mut candidates = compose::addresses(&base.orig_to);
3186 candidates.extend(compose::addresses(&base.orig_cc));
3187 candidates
3188 .into_iter()
3189 .find(|a| self.lists.iter().any(|m| m.is_match(a)))
3190 }
3191
3192 pub fn followup_header(&self, to: &str, cc: Option<&str>, from: &str) -> Option<String> {
3196 if self.lists.is_empty() {
3197 return None;
3198 }
3199 let mut rcpts = compose::addresses(to);
3200 rcpts.extend(compose::addresses(cc.unwrap_or_default()));
3201 if !rcpts
3202 .iter()
3203 .any(|a| self.lists.iter().any(|m| m.is_match(a)))
3204 {
3205 return None;
3206 }
3207 let subscribed = rcpts
3208 .iter()
3209 .any(|a| self.subscribed.iter().any(|m| m.is_match(a)));
3210 let value = compose::followup_to(to, cc.unwrap_or_default(), self.me(), subscribed, from);
3211 (!value.is_empty()).then_some(value)
3212 }
3213
3214 pub fn edit_headers(&self) -> bool {
3217 self.config.mail.edit_headers.unwrap_or(false)
3218 }
3219
3220 pub fn stage_draft(&self, text: &str) -> Result<(PathBuf, Option<String>)> {
3224 let text = compose::apply_my_hdr(text, &self.config.mail.my_hdr);
3228 if self.edit_headers() {
3229 return Ok((write_draft(&text)?, None));
3230 }
3231 let (head, body) = text.split_once("\n\n").unwrap_or((text.trim_end(), ""));
3232 Ok((write_draft(body)?, Some(head.to_string())))
3233 }
3234
3235 pub fn compose_from(&self, base: Option<&ComposeBase>, to: &str) -> Option<String> {
3239 let realname = self.config.identity.reverse_realname.unwrap_or(true);
3243 if self.config.identity.reverse_name
3244 && let Some(b) = base
3245 && let Some(from) = compose::reverse_from(&b.orig_to, &b.orig_cc, self.me(), realname)
3246 {
3247 let rcpts = compose::addresses(to);
3248 return Some(match self.current_identity(&rcpts).name {
3249 Some(name) if !realname && !from.contains('<') => format!("{name} <{from}>"),
3250 _ => from,
3251 });
3252 }
3253 let rcpts = compose::addresses(to);
3254 self.current_identity(&rcpts).from_line()
3255 }
3256
3257 pub fn forward_attaches(&self) -> bool {
3258 self.config.mail.forward.as_deref() == Some("attach")
3259 }
3260
3261 pub fn default_fcc(&self, draft: Option<&Compose>) -> String {
3269 if let Some(compose) = draft
3270 && let Ok(full) = draft_full(compose)
3271 && let Some(mailbox) = self.fcc_hook_target(&full, &compose.path)
3272 {
3273 return mailbox;
3274 }
3275 match &self.imap {
3276 Some(imap) => format!(
3277 "imap:{}/{}",
3278 imap.facts.account.name, imap.facts.account.sent_folder
3279 ),
3280 None => self.config.mail.sent.clone().unwrap_or_default(),
3281 }
3282 }
3283
3284 pub fn default_security(&self) -> Security {
3286 Self::combine(
3287 self.config.pgp.sign_by_default,
3288 self.config.pgp.encrypt_by_default,
3289 )
3290 }
3291
3292 fn combine(sign: bool, encrypt: bool) -> Security {
3293 match (sign, encrypt) {
3294 (true, true) => Security::Both,
3295 (true, false) => Security::Sign,
3296 (false, true) => Security::Encrypt,
3297 (false, false) => Security::None,
3298 }
3299 }
3300
3301 pub fn security_for(&self, kind: &ComposeKind, base: Option<&ComposeBase>) -> Security {
3308 let mut sign = self.config.pgp.sign_by_default;
3309 let mut encrypt = self.config.pgp.encrypt_by_default;
3310 let is_reply = matches!(
3311 kind,
3312 ComposeKind::Reply | ComposeKind::GroupReply | ComposeKind::ListReply
3313 );
3314 if is_reply
3315 && let Some(base) = base
3316 && let Ok(raw) = std::fs::read(&base.path)
3317 {
3318 let crypto = rmut_core::pgp::classify(&raw);
3319 if crypto.encrypted && self.config.pgp.reply_encrypt {
3320 encrypt = true;
3321 }
3322 if crypto.signed && self.config.pgp.reply_sign {
3323 sign = true;
3324 }
3325 if crypto.encrypted && self.config.pgp.reply_sign_encrypted {
3331 sign = true;
3332 }
3333 }
3334 Self::combine(sign, encrypt)
3335 }
3336
3337 pub fn tick_outbox(&mut self) {
3340 while self.outbox.first().is_some_and(|h| h.due <= Instant::now()) {
3341 let held = self.outbox.remove(0);
3342 self.deliver(held);
3343 }
3344 if let Some(next) = self.outbox.first()
3345 && !self.notice().is_some_and(|n| n.is_error())
3346 {
3347 let left = next.due.saturating_duration_since(Instant::now()).as_secs() + 1;
3348 self.note(format!("sending {} in {left}s (z cancels)", next.label));
3349 }
3350 }
3351
3352 pub fn flush_outbox(&mut self) -> Vec<String> {
3359 let mut trouble = Vec::new();
3360 while !self.outbox.is_empty() {
3361 let held = self.outbox.remove(0);
3362 let label = held.label.clone();
3363 self.deliver(held);
3364 if let Some(err) = self.notice().filter(|n| n.is_error()).map(|n| n.text()) {
3365 trouble.push(format!("{label}: {err}"));
3366 self.clear_notice();
3367 }
3368 }
3369 trouble
3370 }
3371
3372 pub fn hold_send(&mut self, held: Held) {
3376 self.outbox.push(held);
3377 }
3378
3379 pub fn cancel_send(&mut self) -> bool {
3382 let Some(held) = self.outbox.pop() else {
3383 return false;
3384 };
3385 self.note(format!("send cancelled: {}", held.label));
3386 self.hand_back(held.state);
3387 true
3388 }
3389
3390 pub fn deliver(&mut self, held: Held) {
3395 let Held {
3396 state: compose_state,
3397 text: final_text,
3398 fcc: chosen,
3399 ..
3400 } = held;
3401 let send_result = match self.smtp_account() {
3402 Some(account) => send_via_smtp(&account, &final_text),
3403 None => run_sendmail(
3404 final_text.as_bytes(),
3405 self.config.mail.sendmail.as_deref(),
3406 None,
3407 ),
3408 };
3409 match send_result {
3410 Ok(()) => {
3411 let mut note = String::from("message sent");
3412 let skip_copy = chosen.as_deref() == Some("")
3413 || (chosen.is_none() && self.config.mail.copy == Some(false));
3414 if skip_copy {
3415 } else if let Some(fcc) = chosen
3417 .as_deref()
3418 .filter(|f| Some(*f) != Some(self.default_fcc(None).as_str()))
3419 {
3420 let dir = expand_tilde(fcc);
3422 let flags = maildir::Flags {
3423 seen: true,
3424 ..Default::default()
3425 };
3426 if dir.join("cur").is_dir()
3427 && maildir::deliver(&dir, final_text.as_bytes(), flags).is_ok()
3428 {
3429 note += &format!(", copy in {fcc}");
3430 } else {
3431 note += &format!(", Fcc to {fcc} failed");
3432 }
3433 } else {
3434 match &mut self.imap {
3435 Some(imap) => match imap.blocking(Job::Append {
3438 mailbox: None,
3439 flags: maildir::Flags {
3440 seen: true,
3441 ..Default::default()
3442 },
3443 body: final_text.clone().into_bytes(),
3444 }) {
3445 Ok(Done::Folder(folder)) => note += &format!(", copy in {folder}"),
3446 Ok(_) => note += ", copy in Sent",
3447 Err(_) => note += ", Fcc to Sent failed",
3448 },
3449 None => {
3450 let sent_dir = self
3451 .config
3452 .mail
3453 .sent
3454 .as_deref()
3455 .map(expand_tilde)
3456 .filter(|p| p.join("cur").is_dir())
3457 .or_else(|| {
3458 maildir::find_special(&self.dir, &["sent", "sent-mail"])
3459 });
3460 match sent_dir {
3461 Some(sent) => {
3462 let flags = maildir::Flags {
3463 seen: true,
3464 ..Default::default()
3465 };
3466 match maildir::deliver(&sent, final_text.as_bytes(), flags) {
3467 Ok(_) => note += ", copy in Sent",
3468 Err(_) => note += ", Fcc to Sent failed",
3469 }
3470 }
3471 None => note += " (no Sent maildir, no copy kept)",
3472 }
3473 }
3474 }
3475 }
3476 let _ = std::fs::remove_file(&compose_state.path);
3477 if let Some(src) = &compose_state.recall_source {
3478 let _ = std::fs::remove_file(src);
3479 }
3480 self.note(note);
3481 }
3482 Err(err) => {
3483 self.error(format!("send failed: {err:#}"));
3484 self.hand_back(compose_state);
3485 }
3486 }
3487 }
3488
3489 pub fn attachment_forgotten(&self, raw: &str, compose: &Compose) -> bool {
3494 if self.config.mail.abort_noattach.as_deref().unwrap_or("no") == "no" {
3495 return false;
3496 }
3497 if compose.attach.is_some() || !compose::extract_attachments(raw).1.is_empty() {
3498 return false;
3499 }
3500 let body = raw.split_once("\n\n").map_or("", |(_, body)| body);
3501 for line in body.lines() {
3502 if line.trim_end() == "--" || line == "-- " {
3503 break;
3504 }
3505 if self.quote_re.is_match(line) {
3506 continue;
3507 }
3508 if self.attach_re.is_match(line) {
3509 return true;
3510 }
3511 }
3512 false
3513 }
3514
3515 pub fn secure_message(
3519 &self,
3520 security: Security,
3521 text: String,
3522 files: &[compose::Attachment],
3523 original: Option<&[u8]>,
3524 ) -> Result<String> {
3525 let cfg = &self.config.pgp;
3526 let recipients = |text: &str| -> Result<Vec<String>> {
3529 let (mut rcpts, _) = compose::smtp_envelope(text)?;
3530 if let Some(from) = compose::from_address(text) {
3531 rcpts.push(from);
3532 }
3533 for r in &mut rcpts {
3536 if let Some(key) = self.crypt_key_for(r) {
3537 *r = key;
3538 }
3539 }
3540 rcpts.sort();
3541 rcpts.dedup();
3542 Ok(rcpts)
3543 };
3544 let flowed = self.config.mail.text_flowed;
3545 if files.is_empty() && original.is_none() {
3546 return match security {
3547 Security::None if flowed => Ok(compose::flow_plain(&text)),
3550 Security::None => Ok(text),
3551 Security::Sign => pgp::sign_message(cfg, &text, flowed),
3552 Security::Encrypt | Security::Both => pgp::encrypt_message(
3553 cfg,
3554 &recipients(&text)?,
3555 security == Security::Both,
3556 &text,
3557 flowed,
3558 ),
3559 };
3560 }
3561 let (head, body) = text.split_once("\n\n").unwrap_or((text.trim_end(), ""));
3562 let entity = compose::mixed_entity(body, files, original, flowed)?;
3563 match security {
3564 Security::None => Ok(format!("{}\nMIME-Version: 1.0\n{entity}", head.trim_end())),
3565 Security::Sign => pgp::sign_entity(cfg, head, entity.as_bytes()),
3566 Security::Encrypt | Security::Both => pgp::encrypt_entity(
3567 cfg,
3568 &recipients(&text)?,
3569 security == Security::Both,
3570 head,
3571 entity.as_bytes(),
3572 ),
3573 }
3574 }
3575
3576 pub fn postponed_dir(&self) -> Option<PathBuf> {
3577 self.config
3578 .mail
3579 .postponed
3580 .as_deref()
3581 .map(expand_tilde)
3582 .filter(|p| p.join("cur").is_dir())
3583 .or_else(|| {
3584 maildir::find_special(&self.dir, &["drafts", "postponed", "rmut-postponed"])
3585 })
3586 }
3587
3588 pub fn has_postponed(&self) -> bool {
3589 self.postponed_dir()
3590 .and_then(|d| maildir::scan(&d).ok())
3591 .is_some_and(|files| !files.is_empty())
3592 }
3593
3594 pub fn postpone_draft(&mut self, compose_state: Compose) {
3597 let target = match self.postponed_dir() {
3598 Some(d) => Ok(d),
3599 None => {
3600 let d = self.dir.join(".rmut-postponed");
3601 maildir::create(&d).map(|()| d)
3602 }
3603 };
3604 let result = target.and_then(|dir| {
3605 let bytes = draft_full(&compose_state)?.into_bytes();
3608 let flags = maildir::Flags {
3609 draft: true,
3610 seen: true,
3611 ..Default::default()
3612 };
3613 maildir::deliver(&dir, &bytes, flags)
3614 });
3615 match result {
3616 Ok(path) => {
3617 let _ = std::fs::remove_file(&compose_state.path);
3618 self.note(format!("postponed to {}", path.display()));
3619 }
3620 Err(err) => {
3621 self.error(format!(
3622 "postpone failed: {err:#}; draft at {}",
3623 compose_state.path.display()
3624 ));
3625 }
3626 }
3627 }
3628
3629 pub fn recall_file(&mut self, source: PathBuf) -> Option<Compose> {
3632 let result = std::fs::read_to_string(&source)
3633 .map_err(anyhow::Error::from)
3634 .and_then(|content| self.stage_draft(&content));
3635 match result {
3636 Ok((path, hidden_head)) => Some(Compose {
3637 path,
3638 recall_source: Some(source),
3639 security: self.default_security(),
3640 attach: None,
3641 hidden_head,
3642 fcc: None,
3643 }),
3644 Err(err) => {
3645 self.error(format!("cannot recall: {err:#}"));
3646 None
3647 }
3648 }
3649 }
3650
3651 pub fn draft_head(&self) -> String {
3653 let Some(c) = &self.draft else {
3654 return String::new();
3655 };
3656 match &c.hidden_head {
3657 Some(head) => head.clone(),
3658 None => {
3659 let text = std::fs::read_to_string(&c.path).unwrap_or_default();
3660 match text.split_once("\n\n") {
3661 Some((head, _)) => head.to_string(),
3662 None => text.trim_end().to_string(),
3663 }
3664 }
3665 }
3666 }
3667
3668 pub fn edit_draft_head(&mut self, f: impl Fn(&str) -> String) {
3670 let Some(c) = &mut self.draft else {
3671 return;
3672 };
3673 match &mut c.hidden_head {
3674 Some(head) => *head = f(head),
3675 None => {
3676 if let Ok(text) = std::fs::read_to_string(&c.path) {
3677 let (head, body) = text.split_once("\n\n").unwrap_or((text.trim_end(), ""));
3678 let _ = std::fs::write(&c.path, format!("{}\n\n{body}", f(head)));
3679 }
3680 }
3681 }
3682 }
3683
3684 pub fn set_draft_header(&mut self, name: &str, value: &str) {
3686 let value = value.trim().to_string();
3687 let name = name.to_string();
3688 self.edit_draft_head(|head| {
3689 let mut lines: Vec<&str> = head
3690 .lines()
3691 .filter(|l| {
3692 !l.split_once(':')
3693 .is_some_and(|(k, _)| k.trim().eq_ignore_ascii_case(&name))
3694 })
3695 .collect();
3696 let added = format!("{name}: {value}");
3697 if !value.is_empty() {
3698 lines.push(&added);
3699 }
3700 lines.join("\n")
3701 });
3702 }
3703
3704 pub fn draft_header(&self, name: &str) -> String {
3705 header_value(&self.draft_head(), name).unwrap_or_default()
3706 }
3707
3708 pub fn send_draft(&mut self) -> Option<Ask> {
3712 let compose_state = self.draft.take()?;
3713 let raw = match draft_full(&compose_state) {
3714 Ok(r) => r,
3715 Err(err) => {
3716 self.error(format!("cannot read draft: {err}"));
3717 return None;
3718 }
3719 };
3720 if !mem::take(&mut self.attach_confirmed) && self.attachment_forgotten(&raw, &compose_state)
3723 {
3724 self.draft = Some(compose_state);
3725 match self.config.mail.abort_noattach.as_deref() {
3726 Some("yes") => {
3729 self.error("no attachment: not sent (abort_noattach); a attaches one");
3730 self.requests.push(Request::ShowDraft);
3731 return None;
3732 }
3733 _ => {
3734 return Some(Ask::Key {
3735 label:
3736 "The body mentions an attachment and none is attached. Send? (y/n): "
3737 .into(),
3738 what: AskKind::NoAttach,
3739 });
3740 }
3741 }
3742 }
3743 let draft_path = self
3746 .draft
3747 .as_ref()
3748 .map(|c| c.path.clone())
3749 .unwrap_or_default();
3750 let hook_fcc = self.fcc_hook_target(&raw, &draft_path);
3751 let (raw, files) = compose::extract_attachments(&raw);
3752 let host = maildir::hostname();
3753 let from = self
3754 .current_identity(&[])
3755 .from_line()
3756 .unwrap_or_else(|| default_from(&host));
3757 let msg_host = self
3759 .config
3760 .mail
3761 .hostname
3762 .clone()
3763 .filter(|h| !h.trim().is_empty())
3764 .unwrap_or(host);
3765 let final_text = match compose::finalize_with(
3766 &raw,
3767 &from,
3768 &compose::make_message_id(&msg_host),
3769 &compose::rfc2822_now(),
3770 self.config.mail.user_agent.unwrap_or(false),
3771 ) {
3772 Ok(t) => t,
3773 Err(err) => {
3774 self.error(format!("{err}; press e to edit"));
3775 self.hand_back(compose_state);
3776 return None;
3777 }
3778 };
3779 let original = match &compose_state.attach {
3780 Some(path) => match std::fs::read(path) {
3781 Ok(bytes) => Some(bytes),
3782 Err(err) => {
3783 self.error(format!("cannot attach the original: {err}"));
3784 self.hand_back(compose_state);
3785 return None;
3786 }
3787 },
3788 None => None,
3789 };
3790 let final_text = match self.secure_message(
3791 compose_state.security,
3792 final_text,
3793 &files,
3794 original.as_deref(),
3795 ) {
3796 Ok(t) => t,
3797 Err(err) => {
3798 self.error(format!("{err:#}; e edits, s changes security"));
3799 self.hand_back(compose_state);
3800 return None;
3801 }
3802 };
3803 let held = Held {
3806 text: final_text,
3807 fcc: compose_state.fcc.clone().or(hook_fcc),
3808 label: {
3809 let head = raw.split_once("\n\n").map_or(raw.as_str(), |(h, _)| h);
3810 header_value(head, "Subject")
3811 .filter(|s| !s.trim().is_empty())
3812 .or_else(|| header_value(head, "To"))
3813 .unwrap_or_else(|| "message".into())
3814 },
3815 state: compose_state,
3816 due: Instant::now() + Duration::from_secs(self.config.mail.undo_send),
3817 };
3818 if self.config.mail.undo_send > 0 {
3820 self.note(format!(
3821 "sending {} in {}s (z cancels)",
3822 held.label, self.config.mail.undo_send
3823 ));
3824 self.hold_send(held);
3825 return None;
3826 }
3827 self.deliver(held);
3828 None
3829 }
3830
3831 fn confirm_attachment(&mut self) {
3834 self.attach_confirmed = true;
3835 }
3836
3837 fn hand_back(&mut self, draft: Compose) {
3839 self.draft = Some(draft);
3840 self.requests.push(Request::ShowDraft);
3841 }
3842
3843 pub fn draft(&self) -> Option<&Compose> {
3845 self.draft.as_ref()
3846 }
3847
3848 pub fn draft_mut(&mut self) -> Option<&mut Compose> {
3849 self.draft.as_mut()
3850 }
3851
3852 pub fn take_draft(&mut self) -> Option<Compose> {
3855 self.draft.take()
3856 }
3857
3858 pub fn set_draft(&mut self, draft: Compose) {
3865 if let Some((path, staged)) = self.staged.take()
3866 && self.config.mail.abort_unmodified.unwrap_or(true)
3867 && path == draft.path
3868 && std::fs::read_to_string(&draft.path).is_ok_and(|now| now == staged)
3869 {
3870 let _ = std::fs::remove_file(&draft.path);
3871 self.draft = None;
3872 self.error("aborted unmodified message");
3873 return;
3874 }
3875 self.draft = Some(draft);
3876 }
3877
3878 pub fn set_attach_field(&mut self, k: usize, input: &str, is_type: bool) {
3883 let value = input.trim().to_string();
3884 self.edit_draft_head(|head| {
3885 let mut seen = 0usize;
3886 head.lines()
3887 .map(|l| {
3888 let is_attach = l
3889 .split_once(':')
3890 .is_some_and(|(key, _)| key.trim().eq_ignore_ascii_case("attach"));
3891 if is_attach {
3892 let (_, mut atts) = compose::extract_attachments(l);
3895 if let Some(mut a) = atts.pop() {
3896 let idx = seen;
3897 seen += 1;
3898 if idx == k {
3899 let new = (!value.is_empty()).then(|| value.clone());
3900 if is_type {
3901 a.mime = new;
3902 } else {
3903 a.description = new;
3904 }
3905 return compose::attach_line(&a);
3906 }
3907 }
3908 }
3909 l.to_string()
3910 })
3911 .collect::<Vec<_>>()
3912 .join("\n")
3913 });
3914 }
3915
3916 pub fn detach(&mut self, sel: usize) {
3920 let fixed = 1 + usize::from(self.draft().is_some_and(|c| c.attach.is_some()));
3921 if sel < fixed {
3922 self.error("only Attach: files can be detached");
3923 return;
3924 }
3925 let k = sel - fixed;
3926 self.edit_draft_head(|head| {
3927 let mut seen = 0usize;
3928 head.lines()
3929 .filter(|l| {
3930 let is_attach = l
3931 .split_once(':')
3932 .is_some_and(|(key, _)| key.trim().eq_ignore_ascii_case("attach"));
3933 if is_attach {
3934 seen += 1;
3935 seen - 1 != k
3936 } else {
3937 true
3938 }
3939 })
3940 .collect::<Vec<_>>()
3941 .join("\n")
3942 });
3943 }
3944
3945 pub fn attach_file(&mut self, input: &str) {
3949 let input = input.trim();
3950 if !input.is_empty() {
3951 if !expand_tilde(input).is_file() {
3952 self.error(format!("{input} is not a file"));
3953 } else if let Some(c) = self.draft_mut() {
3954 let value = if input.contains(char::is_whitespace) && !input.starts_with('"') {
3957 format!("\"{input}\"")
3958 } else {
3959 input.to_string()
3960 };
3961 let result = match &mut c.hidden_head {
3962 Some(head) => {
3964 *head = format!("{}\nAttach: {value}", head.trim_end());
3965 Ok(())
3966 }
3967 None => std::fs::read_to_string(&c.path).and_then(|text| {
3968 let updated = match text.split_once("\n\n") {
3969 Some((head, body)) => format!("{head}\nAttach: {value}\n\n{body}"),
3970 None => format!("{}\nAttach: {value}\n", text.trim_end()),
3971 };
3972 std::fs::write(&c.path, updated)
3973 }),
3974 };
3975 if let Err(err) = result {
3976 self.error(format!("cannot attach: {err}"));
3977 }
3978 }
3979 }
3980 self.requests.push(Request::ShowDraft);
3981 }
3982
3983 pub fn error(&mut self, msg: impl Into<String>) {
3984 self.notify(Notice::Error(msg.into()));
3985 }
3986
3987 pub fn note(&mut self, msg: impl Into<String>) {
3989 self.notify(Notice::Info(msg.into()));
3990 }
3991
3992 fn notify(&mut self, notice: Notice) {
3997 if (notice.is_error() && self.config.ui.beep)
4000 || (notice.is_new_mail() && self.config.ui.beep_new)
4001 {
4002 use std::io::Write as _;
4003 let mut out = std::io::stdout();
4004 let _ = out.write_all(b"\x07");
4005 let _ = out.flush();
4006 }
4007 self.notices.notice(notice);
4008 }
4009
4010 pub fn notice(&self) -> Option<&Notice> {
4013 self.notices.latest()
4014 }
4015}
4016
4017fn subject_key(subject: &str) -> String {
4019 let mut key = subject.trim().to_lowercase();
4020 loop {
4021 let stripped = key
4022 .strip_prefix("re:")
4023 .or_else(|| key.strip_prefix("fwd:"))
4024 .or_else(|| key.strip_prefix("fw:"))
4025 .map(|rest| rest.trim_start().to_string());
4026 match stripped {
4027 Some(next) => key = next,
4028 None => break,
4029 }
4030 }
4031 key
4032}
4033
4034pub fn parse_sort(spec: &str) -> Option<(SortKey, bool)> {
4036 let (rev, name) = match spec.strip_prefix("reverse-") {
4037 Some(rest) => (true, rest),
4038 None => (false, spec),
4039 };
4040 let key = match name {
4041 "date" | "date-sent" | "date-received" => SortKey::Date,
4042 "from" => SortKey::From,
4043 "subject" => SortKey::Subject,
4044 "size" => SortKey::Size,
4045 "threads" => SortKey::Threads,
4046 "label" => SortKey::Label,
4047 "to" => SortKey::To,
4048 "unsorted" | "mailbox-order" => SortKey::Unsorted,
4049 _ => return None,
4050 };
4051 Some((key, rev && key != SortKey::Threads))
4053}
4054
4055impl pattern::EnvSource for Session {
4056 fn envelope(&self, i: usize) -> Option<&Envelope> {
4057 self.msgs.get(i).map(|m| &m.env)
4058 }
4059}
4060
4061fn dir_mtimes(dir: &Path) -> (Option<SystemTime>, Option<SystemTime>) {
4062 let mtime = |p: PathBuf| p.metadata().and_then(|m| m.modified()).ok();
4063 (mtime(dir.join("new")), mtime(dir.join("cur")))
4064}
4065
4066pub fn account_password(account: &Account) -> Result<String> {
4069 if !matches!(account.auth_kind()?, rmut_core::config::AuthKind::Password) {
4070 return account.secret();
4071 }
4072 static CACHE: Mutex<Option<HashMap<String, String>>> = Mutex::new(None);
4073 let mut cache = CACHE.lock().unwrap();
4074 let map = cache.get_or_insert_with(HashMap::new);
4075 if let Some(password) = map.get(&account.name) {
4076 return Ok(password.clone());
4077 }
4078 let password = account.password()?;
4079 map.insert(account.name.clone(), password.clone());
4080 Ok(password)
4081}
4082
4083pub fn wrap_order(n: usize, sel: usize, forward: bool) -> Vec<(usize, bool)> {
4088 (1..=n)
4089 .map(|step| {
4090 if forward {
4091 ((sel + step) % n, sel + step >= n)
4092 } else {
4093 ((sel + n - (step % n)) % n, step > sel)
4094 }
4095 })
4096 .collect()
4097}
4098
4099pub fn expand_tilde(input: &str) -> PathBuf {
4102 if let Some(rest) = input.strip_prefix("~/")
4103 && let Ok(home) = std::env::var("HOME")
4104 {
4105 return Path::new(&home).join(rest);
4106 }
4107 PathBuf::from(input)
4108}
4109
4110pub fn send_via_smtp(account: &Account, text: &str) -> Result<()> {
4111 let from = compose::from_address(text).context("cannot parse the From address")?;
4112 let (rcpts, text) = compose::smtp_envelope(text)?;
4113 anyhow::ensure!(!rcpts.is_empty(), "no recipient addresses");
4114 let password = account_password(account)?;
4115 smtp::send(account, &password, &from, &rcpts, text.as_bytes())
4116}
4117
4118pub fn pipe_to(command: &str, bytes: &[u8]) -> Result<()> {
4120 let mut child = Command::new("sh")
4121 .arg("-c")
4122 .arg(command)
4123 .stdin(Stdio::piped())
4124 .stdout(Stdio::null())
4125 .stderr(Stdio::null())
4126 .spawn()
4127 .with_context(|| format!("running {command}"))?;
4128 {
4132 let mut stdin = child.stdin.take().context("no stdin on the child")?;
4133 if let Err(err) = stdin.write_all(bytes)
4134 && err.kind() != std::io::ErrorKind::BrokenPipe
4135 {
4136 return Err(err.into());
4137 }
4138 }
4139 let status = child.wait()?;
4140 anyhow::ensure!(status.success(), "{command} exited with {status}");
4141 Ok(())
4142}
4143
4144pub fn run_sendmail(
4147 bytes: &[u8],
4148 configured: Option<&str>,
4149 rcpts: Option<&[String]>,
4150) -> Result<()> {
4151 let command = std::env::var("RMUT_SENDMAIL")
4152 .ok()
4153 .or_else(|| configured.map(String::from));
4154 let (prog, mut args) = match command {
4155 Some(v) => {
4156 let mut it = v.split_whitespace().map(String::from);
4157 let p = it.next().context("sendmail command is empty")?;
4158 (p, it.collect::<Vec<_>>())
4159 }
4160 None => {
4161 let p = if Path::new("/usr/sbin/sendmail").exists() {
4162 "/usr/sbin/sendmail".to_string()
4163 } else {
4164 "sendmail".to_string()
4165 };
4166 (p, Vec::new())
4167 }
4168 };
4169 match rcpts {
4170 Some(rcpts) => {
4171 args.push("-oi".into());
4172 args.extend(rcpts.iter().cloned());
4173 }
4174 None => args.extend(["-t".into(), "-oi".into()]),
4175 }
4176 let mut child = Command::new(&prog)
4177 .args(&args)
4178 .stdin(Stdio::piped())
4179 .stdout(Stdio::null())
4180 .stderr(Stdio::null())
4181 .spawn()
4182 .with_context(|| format!("running {prog}"))?;
4183 child
4184 .stdin
4185 .take()
4186 .context("no stdin on sendmail child")?
4187 .write_all(bytes)?;
4188 let status = child.wait()?;
4189 anyhow::ensure!(status.success(), "{prog} exited with {status}");
4190 Ok(())
4191}
4192
4193pub fn default_from(hostname: &str) -> String {
4194 if let Ok(email) = std::env::var("EMAIL") {
4195 return email;
4196 }
4197 let user = std::env::var("USER").unwrap_or_else(|_| "user".into());
4198 format!("{user}@{hostname}")
4199}
4200
4201const DEFAULT_ATTACH_KEYWORD: &str = r"\b(attach|attached|attaching|attachment|attachments)\b";
4204
4205fn attach_re_from_config(config: &Config, warnings: &mut Vec<String>) -> regex_lite::Regex {
4208 let spec = config
4209 .mail
4210 .attach_keyword
4211 .clone()
4212 .unwrap_or_else(|| DEFAULT_ATTACH_KEYWORD.to_string());
4213 match regex_lite::Regex::new(&format!("(?i){spec}")) {
4214 Ok(re) => re,
4215 Err(err) => {
4216 warnings.push(format!("bad attach_keyword {spec:?}: {err}"));
4217 default_attach_re()
4218 }
4219 }
4220}
4221
4222fn default_attach_re() -> regex_lite::Regex {
4223 regex_lite::Regex::new(&format!("(?i){DEFAULT_ATTACH_KEYWORD}")).expect("the default compiles")
4224}
4225
4226fn reply_re_from_config(config: &Config, warnings: &mut Vec<String>) -> regex_lite::Regex {
4229 match &config.mail.reply_regexp {
4230 Some(spec) => match compose::reply_regexp(spec) {
4231 Ok(re) => re,
4232 Err(err) => {
4233 warnings.push(format!("bad reply_regexp {spec:?}: {err}"));
4234 compose::default_reply_regexp()
4235 }
4236 },
4237 None => compose::default_reply_regexp(),
4238 }
4239}
4240
4241fn quote_re_from_config(config: &Config, warnings: &mut Vec<String>) -> regex_lite::Regex {
4244 match &config.pager.quote_regexp {
4245 Some(spec) => match regex_lite::Regex::new(spec) {
4246 Ok(re) => re,
4247 Err(err) => {
4248 warnings.push(format!("bad quote_regexp {spec:?}: {err}"));
4249 default_quote_re()
4250 }
4251 },
4252 None => default_quote_re(),
4253 }
4254}
4255
4256fn display_from_config(config: &Config) -> message::Display {
4259 let mut rules = message::HeaderRules::default();
4263 let clean = |list: &Vec<String>| {
4264 list.iter()
4265 .map(|n| n.trim_end_matches(':').to_lowercase())
4266 .collect::<Vec<_>>()
4267 };
4268 if let Some(list) = &config.pager.ignore {
4269 rules.ignore = clean(list);
4270 }
4271 if let Some(list) = &config.pager.unignore {
4272 rules.unignore = clean(list);
4273 }
4274 if let Some(list) = &config.pager.hdr_order {
4275 rules.order = clean(list);
4276 }
4277 let mut filters: HashMap<String, String> = HashMap::new();
4282 let mut mailcap: Option<Vec<rmut_core::mailcap::Entry>> = None;
4283 for (mimetype, command) in &config.filters {
4284 let mimetype = mimetype.to_lowercase();
4285 if !command.trim().is_empty() {
4286 filters.insert(mimetype, command.clone());
4287 continue;
4288 }
4289 let entries = mailcap.get_or_insert_with(rmut_core::mailcap::load);
4290 if let Some(command) = rmut_core::mailcap::command_for(entries, &mimetype) {
4291 filters.insert(mimetype, command);
4292 }
4293 }
4294 message::Display {
4295 filters,
4296 rules,
4297 reflow: config.pager.reflow_text.unwrap_or(true),
4298 alternative_order: config
4299 .pager
4300 .alternative_order
4301 .iter()
4302 .map(|t| t.to_lowercase())
4303 .collect(),
4304 }
4305}
4306
4307fn compile_hooks<'a>(
4310 what: &str,
4311 entries: impl Iterator<Item = (&'a String, &'a String)>,
4312 warnings: &mut Vec<String>,
4313) -> Vec<Hook> {
4314 let mut out = Vec::new();
4315 for (spec, value) in entries {
4316 if value.trim().is_empty() {
4317 warnings.push(format!("{what} {spec:?} has nothing to do"));
4318 continue;
4319 }
4320 match pattern::parse(spec) {
4321 Ok(patterns) => out.push(Hook {
4322 patterns,
4323 value: value.clone(),
4324 }),
4325 Err(err) => warnings.push(format!("bad {what} pattern {spec:?}: {err}")),
4326 }
4327 }
4328 out
4329}
4330
4331pub fn header_value(head: &str, name: &str) -> Option<String> {
4333 head.lines().find_map(|l| {
4334 let (k, v) = l.split_once(':')?;
4335 k.trim()
4336 .eq_ignore_ascii_case(name)
4337 .then(|| v.trim().to_string())
4338 })
4339}
4340
4341pub fn draft_full(c: &Compose) -> std::io::Result<String> {
4344 let text = std::fs::read_to_string(&c.path)?;
4345 Ok(match &c.hidden_head {
4346 Some(head) => format!("{}\n\n{}", head.trim_end(), text),
4347 None => text,
4348 })
4349}
4350
4351pub fn default_quote_re() -> regex_lite::Regex {
4353 regex_lite::Regex::new(r"^([ \t]*[|>:}#])+").expect("default quote_regexp compiles")
4354}
4355
4356pub fn write_draft(text: &str) -> Result<PathBuf> {
4357 use std::sync::atomic::{AtomicUsize, Ordering};
4358 static COUNTER: AtomicUsize = AtomicUsize::new(0);
4359 let path = std::env::temp_dir().join(format!(
4360 "rmut-draft-{}-{}.eml",
4361 std::process::id(),
4362 COUNTER.fetch_add(1, Ordering::Relaxed),
4363 ));
4364 std::fs::write(&path, text).with_context(|| format!("writing {}", path.display()))?;
4365 Ok(path)
4366}