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::mpsc::{Receiver, TryRecvError};
15use std::sync::{Arc, Mutex};
16use std::time::{Duration, Instant, SystemTime};
17
18use anyhow::{Context, Result};
19use rmut_core::config::{Account, Config};
20use rmut_core::message::Envelope;
21use rmut_core::notice::{Notice, NoticeSink};
22use rmut_core::pattern::{self, Pattern};
23use rmut_core::remote::{self, Remote};
24use rmut_core::{alias, compose, hdrcache, maildir, mbox, message, pgp, smtp, thread};
25
26mod ask;
27mod commands;
28mod drafts;
29mod function;
30#[cfg(test)]
31mod tests;
32mod worker;
33
34pub use ask::{Answer, Ask, AskKind, Key, PatternOp, Request, Wants};
35pub use commands::CommandRun;
36pub use function::{FrontOp, Function, Outcome, PageSpot, SidebarOp};
37pub use worker::{Done, Facts, Imap, Job, Manage};
38
39const UNDO_MAX_STEPS: usize = 32;
43const UNDO_MAX_MARKS: usize = 100_000;
44
45pub struct Msg {
46 pub env: message::Envelope,
47 pub dirty: bool,
49 pub purge: bool,
52}
53
54impl Msg {
55 pub fn pending(&self) -> bool {
56 self.dirty || self.env.file.flags.deleted
57 }
58}
59
60#[derive(Clone)]
64pub struct MsgMark {
65 pub path: PathBuf,
66 pub flags: maildir::Flags,
67 pub is_new: bool,
68 pub tagged: bool,
69 pub dirty: bool,
70}
71
72pub struct UndoStep {
75 pub what: String,
76 pub marks: Vec<MsgMark>,
77 pub sel: Option<PathBuf>,
80 pub created: Vec<PathBuf>,
83 pub note: Option<String>,
86 pub rewritten: Vec<(PathBuf, Vec<u8>)>,
89}
90
91#[derive(Clone, Copy, PartialEq, Eq, Debug)]
92pub enum SortKey {
93 Date,
94 From,
95 Subject,
96 Size,
97 Threads,
98 Label,
100 To,
102 Unsorted,
105}
106
107impl SortKey {
108 pub fn name(self) -> &'static str {
109 match self {
110 SortKey::Date => "date",
111 SortKey::From => "from",
112 SortKey::Subject => "subject",
113 SortKey::Size => "size",
114 SortKey::Threads => "threads",
115 SortKey::Label => "label",
116 SortKey::To => "to",
117 SortKey::Unsorted => "unsorted",
118 }
119 }
120}
121
122#[derive(Clone, Copy, PartialEq, Eq)]
123pub enum ThreadOp {
124 Delete,
125 Undelete,
126 Tag,
127 Read,
129}
130
131fn mailbox_exists(spec: &str) -> bool {
135 match remote::parse_spec(spec) {
136 Some(_) => true,
137 None => expand_tilde(spec).join("cur").is_dir(),
138 }
139}
140
141pub struct Hook {
144 patterns: Vec<Pattern>,
145 pub value: String,
147}
148
149#[derive(Clone, Copy, PartialEq, Eq)]
150pub enum ComposeKind {
151 New,
152 Reply,
153 GroupReply,
154 ListReply,
156 Forward,
157}
158
159pub struct ComposeBase {
162 pub path: PathBuf,
163 pub reply_to: String,
164 pub from_hdr: String,
167 pub has_reply_to: bool,
170 pub orig_to: String,
171 pub orig_cc: String,
172 pub list_post: Option<String>,
174 pub followup_to: String,
177 pub from_addr: String,
179 pub from_display: String,
180 pub subject: String,
181 pub date: i64,
182 pub msg_id: Option<String>,
183 pub references: Vec<String>,
184}
185
186pub struct ComposeSetup {
187 pub kind: ComposeKind,
188 pub base: Option<ComposeBase>,
189 pub to: Option<String>,
190 pub cc: Option<String>,
192 pub bcc: Option<String>,
193 pub subject_prefill: Option<String>,
196 pub subject: Option<String>,
198 pub fwd_attach: Option<bool>,
200 pub part: Option<usize>,
202}
203
204#[derive(Clone, Copy, PartialEq, Eq, Debug)]
206pub enum Security {
207 None,
208 Sign,
209 Encrypt,
210 Both,
211}
212
213impl Security {
214 pub fn label(self) -> &'static str {
215 match self {
216 Security::None => "",
217 Security::Sign => "sign",
218 Security::Encrypt => "encrypt",
219 Security::Both => "sign+encrypt",
220 }
221 }
222}
223
224pub struct Compose {
226 pub path: PathBuf,
227 pub recall_source: Option<PathBuf>,
229 pub security: Security,
230 pub attach: Option<PathBuf>,
233 pub hidden_head: Option<String>,
236 pub fcc: Option<String>,
239}
240
241pub struct Held {
246 pub state: Compose,
247 pub text: String,
248 pub fcc: Option<String>,
251 pub fcc_text: Option<String>,
254 pub label: String,
256 pub due: Instant,
257}
258
259#[derive(Clone, Copy)]
265pub struct DeleteRules {
266 pub flag_safe: bool,
267 pub untag: bool,
268}
269
270impl DeleteRules {
271 pub fn mark(self, m: &mut Msg) -> bool {
275 if self.flag_safe && m.env.file.flags.flagged {
276 return false;
277 }
278 m.env.file.flags.deleted = true;
279 if self.untag {
280 m.env.tagged = false;
281 }
282 true
283 }
284}
285
286enum Pending {
287 CheckNew,
289 Sync { purge: bool },
292 Again(Again),
295 Counts(Vec<String>),
297 Then { hold: bool, then: Then },
300}
301
302impl Pending {
303 fn holds(&self) -> bool {
304 matches!(self, Pending::Then { hold: true, .. })
305 }
306}
307
308type Then = Box<dyn FnOnce(&mut Session, Result<Done>) + Send>;
311
312enum Deferred {
314 Again(Again),
316 Job(Job, Pending),
318}
319
320impl Deferred {
321 fn holds(&self) -> bool {
322 matches!(self, Deferred::Job(_, pending) if pending.holds())
323 }
324
325 fn outlives_abort(&self) -> bool {
328 matches!(self, Deferred::Job(_, Pending::Then { hold: false, .. }))
329 }
330}
331
332struct Opening {
335 spec: String,
336 read_only: bool,
337 answer: Receiver<Result<(Session, Vec<String>)>>,
338 progress: Arc<Mutex<Option<String>>>,
339}
340
341struct Copied {
343 copied: Vec<usize>,
345 errors: Vec<String>,
346 created: Vec<PathBuf>,
348 target: String,
350 note: Option<String>,
352 delete: bool,
353 tagged: bool,
354}
355
356#[derive(Clone)]
359enum Again {
360 View,
362 Copy {
363 input: String,
364 delete: bool,
365 tagged: bool,
366 decode: bool,
367 },
368 Pipe {
369 command: String,
370 tagged: bool,
371 },
372 Print {
373 tagged: bool,
374 },
375 Bounce {
376 to: String,
377 tagged: bool,
378 },
379}
380
381pub struct Session {
383 pub dir: PathBuf,
386 pub title: String,
389 imap: Option<Imap>,
393 pub mbox: Option<mbox::Mbox>,
395 pub msgs: Vec<Msg>,
396 pub visible: Vec<usize>,
398 pub sel: usize,
400 pub sort: SortKey,
401 pub sort_rev: bool,
402 pub limit: Option<(String, Vec<Pattern>)>,
403 pub last_search: Option<Vec<Pattern>>,
404 pub search_rev: bool,
407 pub thread_depth: Vec<usize>,
410 pub thread_root: Vec<usize>,
411 pub thread_pseudo: Vec<bool>,
414 pub thread_parent: Vec<Option<usize>>,
418 pub thread_children: Vec<Vec<usize>>,
419 pub thread_members: Vec<Vec<usize>>,
420 pub collapsed: HashSet<PathBuf>,
422 undo: Vec<UndoStep>,
425 pub config: Config,
426 pub my_addresses: Vec<String>,
429 pub lists: Vec<pattern::Matcher>,
432 pub subscribed: Vec<pattern::Matcher>,
435 pub alternates: Vec<pattern::Matcher>,
438 body_hits: HashMap<String, HashSet<u32>>,
441 body_local: HashSet<String>,
443 server_folders: Option<Vec<(String, usize)>>,
446 listing: bool,
448 pub idle: Option<remote::IdleWatch>,
450 backfill: Option<remote::Backfill>,
452 mailbox_new: HashMap<String, usize>,
455 error_history: std::collections::VecDeque<String>,
458 mailbox_announced: HashSet<String>,
461 unseen: HashMap<String, usize>,
464 pub read_only: bool,
466 pub read_only_session: bool,
470 pub(crate) dir_mtimes: (Option<SystemTime>, Option<SystemTime>),
472 pub message_hooks: Vec<Hook>,
477 pub reply_hooks: Vec<Hook>,
478 fcc_hooks: Vec<Hook>,
479 crypt_hooks: Vec<(pattern::Matcher, String)>,
480 pub display: message::Display,
483 outbox: Vec<Held>,
486 pub quote_re: regex_lite::Regex,
489 pub reply_re: regex_lite::Regex,
491 attach_re: regex_lite::Regex,
493 pending: Option<Pending>,
496 deferred: std::collections::VecDeque<Deferred>,
500 opening: Option<Opening>,
503 progress_noted: bool,
506 spoken: bool,
510 aborted: bool,
513 quit_default: bool,
516 setup: Option<ComposeSetup>,
519 draft: Option<Compose>,
523 staged: Option<(PathBuf, String)>,
527 parked_forward: Option<Compose>,
530 fcc_attach_answer: Option<bool>,
532 attach_confirmed: bool,
535 hook_base: Option<Box<Config>>,
538 active_message_hooks: Vec<usize>,
541 requests: Vec<Request>,
543 notices: Box<dyn NoticeSink>,
546}
547
548struct Silence;
550
551impl NoticeSink for Silence {
552 fn notice(&mut self, _notice: Notice) {}
553
554 fn latest(&self) -> Option<&Notice> {
555 None
556 }
557
558 fn clear(&mut self) {}
559}
560
561impl Session {
562 pub fn open(dir: &Path, config: Config) -> Result<(Session, Vec<String>)> {
568 let (envelopes, skipped) = hdrcache::load_envelopes(dir)?;
570 let mut msgs: Vec<Msg> = envelopes
571 .into_iter()
572 .map(|env| Msg {
573 env,
574 dirty: false,
575 purge: false,
576 })
577 .collect();
578 msgs.sort_by_key(|m| m.env.date);
580 let visible: Vec<usize> = (0..msgs.len()).collect();
581 let sel = msgs
583 .iter()
584 .position(|m| m.env.file.is_new)
585 .unwrap_or(visible.len().saturating_sub(1));
586 let mut warnings = Vec::new();
587 if skipped > 0 {
588 warnings.push(format!("{skipped} unreadable message(s) skipped"));
589 }
590 let count = msgs.len();
591 let mut me: Vec<String> = config
592 .identity
593 .email
594 .iter()
595 .chain(config.accounts.iter().map(|a| &a.user))
596 .chain(
597 config
598 .accounts
599 .iter()
600 .filter_map(|a| a.identity.as_ref().and_then(|i| i.email.as_ref())),
601 )
602 .chain(config.identities.iter().filter_map(|r| r.email.as_ref()))
603 .map(|a| a.to_lowercase())
604 .collect();
605 if let Ok(email) = std::env::var("EMAIL") {
606 me.push(email.to_lowercase());
607 }
608 let mut session = Session {
609 dir: dir.to_path_buf(),
610 title: dir.display().to_string(),
611 imap: None,
612 mbox: None,
613 msgs,
614 visible,
615 sel,
616 sort: SortKey::Date,
617 sort_rev: false,
618 limit: None,
619 last_search: None,
620 search_rev: false,
621 thread_depth: vec![0; count],
622 thread_root: (0..count).collect(),
623 thread_pseudo: vec![false; count],
624 thread_parent: vec![None; count],
625 thread_children: vec![Vec::new(); count],
626 thread_members: (0..count).map(|i| vec![i]).collect(),
627 collapsed: HashSet::new(),
628 undo: Vec::new(),
629 lists: config.list_matchers(),
630 subscribed: config.subscribed_matchers(),
631 alternates: config.alternate_matchers(),
632 my_addresses: me,
633 body_hits: HashMap::new(),
634 body_local: HashSet::new(),
635 server_folders: None,
636 listing: false,
637 idle: None,
638 backfill: None,
639 mailbox_new: HashMap::new(),
640 error_history: std::collections::VecDeque::new(),
641 mailbox_announced: HashSet::new(),
642 unseen: HashMap::new(),
643 read_only: false,
644 read_only_session: false,
645 dir_mtimes: dir_mtimes(dir),
646 message_hooks: Vec::new(),
647 reply_hooks: Vec::new(),
648 fcc_hooks: Vec::new(),
649 crypt_hooks: Vec::new(),
650 display: display_from_config(&config),
651 outbox: Vec::new(),
652 quote_re: default_quote_re(),
653 reply_re: compose::default_reply_regexp(),
654 attach_re: default_attach_re(),
655 config,
656 pending: None,
657 deferred: std::collections::VecDeque::new(),
658 opening: None,
659 progress_noted: false,
660 spoken: false,
661 aborted: false,
662 quit_default: true,
663 setup: None,
664 draft: None,
665 staged: None,
666 parked_forward: None,
667 fcc_attach_answer: None,
668 attach_confirmed: false,
669 hook_base: None,
670 active_message_hooks: Vec::new(),
671 requests: Vec::new(),
672 notices: Box::new(Silence),
673 };
674 session.apply_timeouts();
675 let mut hook_warnings = Vec::new();
676 session.quote_re = quote_re_from_config(&session.config, &mut hook_warnings);
677 session.reply_re = reply_re_from_config(&session.config, &mut hook_warnings);
678 session.attach_re = attach_re_from_config(&session.config, &mut hook_warnings);
679 session.compile_hooks_from_config(&mut hook_warnings);
680 hook_warnings.append(&mut warnings);
681 let mut warnings = hook_warnings;
682 if let Some(spec) = session.config.index.sort.clone() {
683 match parse_sort(&spec) {
684 Some((sort, rev)) => {
685 session.sort = sort;
686 session.sort_rev = rev;
687 session.resort(None);
688 }
689 None => warnings.push(format!("unknown sort {spec:?} in config")),
690 }
691 }
692 Ok((session, warnings))
693 }
694
695 pub fn open_spec(
702 spec: &str,
703 config: Config,
704 progress: remote::Progress,
705 ) -> Result<(Session, Vec<String>)> {
706 rmut_core::net::set_timeouts(config.net.connect_timeout, config.net.timeout);
709 rmut_core::net::set_trust(
710 config.net.system_cas,
711 config.net.certificate_file.as_deref().map(expand_tilde),
712 );
713 match remote::parse_spec(spec) {
714 Some((account_name, mailbox)) => {
715 let account = config
716 .account(account_name)
717 .with_context(|| format!("no account {account_name} in config"))?
718 .clone();
719 let password = account_password(&account)?;
720 Session::open_remote(&account, mailbox, &password, config, progress)
721 }
722 None => {
723 let path = expand_tilde(spec);
724 if path.is_file() {
725 return Session::open_mbox(&path, config);
726 }
727 Session::open(&path, config)
728 }
729 }
730 }
731
732 fn open_remote(
737 account: &Account,
738 mailbox: &str,
739 password: &str,
740 config: Config,
741 progress: remote::Progress,
742 ) -> Result<(Session, Vec<String>)> {
743 let remote = Remote::open(account, mailbox, password, progress)?;
744 let cache = remote.cache.clone();
745 let (mut session, warnings) = Session::open(&cache, config)?;
746 session.title = remote.spec.clone();
747 session.idle = Some(remote::idle_watch(account, &remote.mailbox, password));
750 session.imap = Some(Imap::new(remote));
751 Ok((session, warnings))
752 }
753
754 pub fn open_mbox(path: &Path, config: Config) -> Result<(Session, Vec<String>)> {
757 let mbox = mbox::Mbox::open(path)?;
758 let cache = mbox.cache.clone();
759 let (mut session, warnings) = Session::open(&cache, config)?;
760 session.title = path.display().to_string();
761 session.mbox = Some(mbox);
762 Ok((session, warnings))
763 }
764
765 pub fn recompile(&mut self) -> Vec<String> {
768 let mut warnings = Vec::new();
769 self.apply_timeouts();
770 self.display = display_from_config(&self.config);
771 self.quote_re = quote_re_from_config(&self.config, &mut warnings);
772 self.reply_re = reply_re_from_config(&self.config, &mut warnings);
773 self.attach_re = attach_re_from_config(&self.config, &mut warnings);
774 self.compile_hooks_from_config(&mut warnings);
775 self.lists = self.config.list_matchers();
776 self.subscribed = self.config.subscribed_matchers();
777 self.alternates = self.config.alternate_matchers();
778 warnings
779 }
780
781 fn apply_timeouts(&self) {
785 rmut_core::net::set_timeouts(self.config.net.connect_timeout, self.config.net.timeout);
786 rmut_core::net::set_trust(
787 self.config.net.system_cas,
788 self.config
789 .net
790 .certificate_file
791 .as_deref()
792 .map(expand_tilde),
793 );
794 }
795
796 fn compile_hooks_from_config(&mut self, warnings: &mut Vec<String>) {
799 self.message_hooks = compile_hooks(
800 "message-hook",
801 self.config
802 .message_hooks
803 .iter()
804 .map(|h| (&h.pattern, &h.command)),
805 warnings,
806 );
807 self.reply_hooks = compile_hooks(
808 "reply-hook",
809 self.config
810 .reply_hooks
811 .iter()
812 .map(|h| (&h.pattern, &h.command)),
813 warnings,
814 );
815 self.fcc_hooks = compile_hooks(
816 "fcc-hook",
817 self.config
818 .fcc_hooks
819 .iter()
820 .map(|h| (&h.pattern, &h.mailbox)),
821 warnings,
822 );
823 self.crypt_hooks = self
824 .config
825 .crypt_hooks
826 .iter()
827 .map(|h| (pattern::Matcher::new(&h.address), h.key.clone()))
828 .collect();
829 }
830
831 pub fn switch_to(&mut self, spec: &str, read_only: bool) {
847 if self.holding() {
848 self.error("still opening the last one (Ctrl+G gives up on it)");
849 return;
850 }
851 let Some((account, mailbox)) = remote::parse_spec(spec) else {
852 let config = self.config.clone();
853 match Session::open_spec(spec, config, Box::new(|_| {})) {
854 Ok((next, warnings)) => self.become_(next, warnings, read_only),
855 Err(err) => self.error(format!("cannot open {spec}: {err:#}")),
856 }
857 return;
858 };
859 let same_account = self
860 .imap
861 .as_ref()
862 .is_some_and(|imap| imap.facts.account.name == account);
863 if !same_account {
864 self.open_in_background(spec, read_only);
865 return;
866 }
867 let spec = spec.to_string();
868 self.send_then(
869 Job::Switch(mailbox.into()),
870 true,
871 Box::new(move |session, done| match done {
872 Ok(_) => session.switched(read_only),
873 Err(_) => session.open_in_background(&spec, read_only),
876 }),
877 );
878 }
879
880 pub fn holding(&self) -> bool {
884 self.opening.is_some()
885 || self.pending.as_ref().is_some_and(Pending::holds)
886 || self.deferred.iter().any(Deferred::holds)
887 }
888
889 fn switched(&mut self, read_only: bool) {
892 let Some(imap) = self.imap.take() else {
893 return;
894 };
895 match Session::open(&imap.facts.cache.clone(), self.config.clone()) {
896 Ok((mut next, warnings)) => {
897 next.title = imap.facts.spec.clone();
898 if let Ok(password) = account_password(&imap.facts.account) {
900 next.idle = Some(remote::idle_watch(
901 &imap.facts.account,
902 &imap.facts.mailbox,
903 &password,
904 ));
905 }
906 next.imap = Some(imap);
907 next.deferred = mem::take(&mut self.deferred)
910 .into_iter()
911 .filter(Deferred::outlives_abort)
912 .collect();
913 self.become_(next, warnings, read_only);
914 }
915 Err(err) => self.error(format!("cannot open {}: {err:#}", imap.facts.spec)),
916 }
917 }
918
919 fn open_in_background(&mut self, spec: &str, read_only: bool) {
922 let Some((account_name, mailbox)) = remote::parse_spec(spec) else {
923 return;
924 };
925 let prepared = self
926 .config
927 .account(account_name)
928 .with_context(|| format!("no account {account_name} in config"))
929 .cloned()
930 .and_then(|account| account_password(&account).map(|password| (account, password)));
931 let (account, password) = match prepared {
932 Ok(prepared) => prepared,
933 Err(err) => {
934 self.error(format!("cannot open {spec}: {err:#}"));
935 return;
936 }
937 };
938 let progress = Arc::new(Mutex::new(None));
939 let (tell, answer) = std::sync::mpsc::channel();
940 let config = self.config.clone();
941 let mailbox = mailbox.to_string();
942 let sink = Imap::progress_sink(&progress);
943 std::thread::spawn(move || {
944 let opened = Session::open_remote(&account, &mailbox, &password, config, sink);
945 let _ = tell.send(opened);
947 });
948 self.note_progress("opening the folder... (Ctrl+G aborts)".into());
949 self.opening = Some(Opening {
950 spec: spec.to_string(),
951 read_only,
952 answer,
953 progress,
954 });
955 }
956
957 fn become_(&mut self, mut next: Session, warnings: Vec<String>, read_only: bool) {
962 next.read_only_session = self.read_only_session;
965 next.read_only = self.read_only_session || read_only;
966 next.notices = mem::replace(&mut self.notices, Box::new(Silence));
967 next.outbox = mem::take(&mut self.outbox);
968 *self = next;
969 self.maybe_backfill();
971 self.requests.push(Request::Opened(warnings));
972 }
973
974 pub fn install_notices(&mut self, sink: Box<dyn NoticeSink>) {
977 self.notices = sink;
978 }
979
980 pub fn clear_notice(&mut self) {
983 self.notices.clear();
984 self.progress_noted = false;
986 self.spoken = false;
987 }
988
989 pub fn new_count(&self) -> usize {
990 self.msgs.iter().filter(|m| m.env.file.is_new).count()
991 }
992
993 pub fn deleted_count(&self) -> usize {
994 self.msgs
995 .iter()
996 .filter(|m| m.env.file.flags.deleted)
997 .count()
998 }
999
1000 pub fn pending_count(&self) -> usize {
1001 self.msgs.iter().filter(|m| m.pending()).count()
1002 }
1003
1004 fn thread_has_unread(&self, root: usize) -> bool {
1006 self.thread_root
1007 .iter()
1008 .enumerate()
1009 .filter(|&(_, &r)| r == root)
1010 .any(|(i, _)| !self.msgs[i].env.file.flags.seen)
1011 }
1012
1013 fn first_unread_in_thread(&self, root: usize) -> Option<PathBuf> {
1016 self.thread_root
1017 .iter()
1018 .enumerate()
1019 .filter(|&(_, &r)| r == root)
1020 .map(|(i, _)| i)
1021 .find(|&i| !self.msgs[i].env.file.flags.seen)
1022 .map(|i| self.msgs[i].env.file.path.clone())
1023 }
1024
1025 pub fn thread_info(&self, mi: usize) -> (usize, Option<usize>) {
1027 if self.sort != SortKey::Threads {
1028 return (0, None);
1029 }
1030 let depth = self.thread_depth.get(mi).copied().unwrap_or(0);
1031 if depth == 0 && self.collapsed.contains(&self.msgs[mi].env.file.path) {
1032 let hidden = self
1033 .thread_root
1034 .iter()
1035 .enumerate()
1036 .filter(|&(j, &r)| r == mi && j != mi)
1037 .count();
1038 if hidden > 0 {
1039 return (0, Some(hidden));
1040 }
1041 }
1042 (depth, None)
1043 }
1044
1045 pub fn select(&mut self, index: usize) {
1046 if self.visible.is_empty() {
1047 return;
1048 }
1049 self.sel = index.min(self.visible.len() - 1);
1050 }
1051
1052 pub fn cur_mut(&mut self) -> Option<&mut Msg> {
1053 let i = self.visible.get(self.sel).copied()?;
1054 self.msgs.get_mut(i)
1055 }
1056
1057 pub fn selected_path(&self) -> Option<PathBuf> {
1058 self.visible
1059 .get(self.sel)
1060 .map(|&i| self.msgs[i].env.file.path.clone())
1061 }
1062
1063 pub fn deny_readonly(&mut self) -> bool {
1066 if self.read_only {
1067 self.error("Mailbox is read-only.");
1068 }
1069 self.read_only
1070 }
1071
1072 pub fn mark_read(&mut self) {
1073 if self.read_only {
1074 return;
1075 }
1076 if let Some(m) = self.cur_mut()
1077 && (m.env.file.is_new || !m.env.file.flags.seen)
1078 {
1079 m.env.file.is_new = false;
1080 m.env.file.flags.seen = true;
1081 m.dirty = true;
1082 }
1083 }
1084
1085 pub fn scope(&self, position: pattern::Position) -> pattern::Scope<'_> {
1088 pattern::Scope {
1089 me: self.me(),
1090 lists: &self.lists,
1091 subscribed: &self.subscribed,
1092 position,
1093 thread: None,
1094 }
1095 }
1096
1097 pub fn scope_at(&self, position: pattern::Position, mi: usize) -> pattern::Scope<'_> {
1101 let thread = (self.sort == SortKey::Threads && mi < self.msgs.len()).then(|| {
1102 let root = self.thread_root.get(mi).copied().unwrap_or(mi);
1103 pattern::ThreadView {
1104 members: self.thread_members.get(root).map_or(&[][..], Vec::as_slice),
1105 parent: self.thread_parent.get(mi).copied().flatten(),
1106 children: self.thread_children.get(mi).map_or(&[][..], Vec::as_slice),
1107 collapsed: self.thread_depth.get(mi) == Some(&0)
1108 && self.collapsed.contains(&self.msgs[mi].env.file.path),
1109 envs: self,
1110 }
1111 });
1112 pattern::Scope {
1113 thread,
1114 ..self.scope(position)
1115 }
1116 }
1117
1118 pub fn me(&self) -> pattern::Me<'_> {
1120 pattern::Me::new(&self.my_addresses, &self.alternates)
1121 }
1122
1123 fn env_matches_at(
1128 &self,
1129 patterns: &[Pattern],
1130 env: &Envelope,
1131 pos: pattern::Position,
1132 mi: usize,
1133 ) -> bool {
1134 pattern::matches_in(
1135 patterns,
1136 env,
1137 self.scope_at(pos, mi),
1138 Some(&|env: &Envelope, m: &pattern::Matcher| {
1139 let set = self.body_hits.get(m.raw())?;
1140 let uid = remote::uid_of(&env.file.path)?;
1141 Some(set.contains(&uid))
1142 }),
1143 )
1144 }
1145
1146 fn positions(&self) -> Vec<pattern::Position> {
1151 let mut seen: HashMap<&str, usize> = HashMap::new();
1152 for m in &self.msgs {
1153 if let Some(id) = m.env.msg_id.as_deref() {
1154 *seen.entry(id).or_default() += 1;
1155 }
1156 }
1157 let mut numbers = vec![0usize; self.msgs.len()];
1158 for (n, &mi) in self.visible.iter().enumerate() {
1159 if let Some(slot) = numbers.get_mut(mi) {
1160 *slot = n + 1;
1161 }
1162 }
1163 let current = self
1164 .visible
1165 .get(self.sel)
1166 .and_then(|&mi| numbers.get(mi).copied())
1167 .unwrap_or(0);
1168 let last = self.visible.len();
1169 (0..self.msgs.len())
1170 .map(|i| pattern::Position {
1171 number: numbers[i],
1172 current,
1173 last,
1174 duplicate: self.msgs[i]
1175 .env
1176 .msg_id
1177 .as_deref()
1178 .is_some_and(|id| seen.get(id).copied().unwrap_or(0) > 1),
1179 })
1180 .collect()
1181 }
1182
1183 fn body_terms_ready(
1193 &mut self,
1194 patterns: &[Pattern],
1195 redo: Box<dyn FnOnce(&mut Session) + Send>,
1196 ) -> bool {
1197 if self.imap.is_none() {
1198 return true;
1199 }
1200 let Some(term) = pattern::body_terms(patterns).into_iter().find(|term| {
1201 let simple = !term
1202 .chars()
1203 .any(|c| r".*+?[](){}|^$\".contains(c) || !c.is_ascii());
1204 simple && !self.body_hits.contains_key(term) && !self.body_local.contains(term)
1205 }) else {
1206 return true;
1207 };
1208 self.send_then(
1209 Job::SearchBody(term.clone()),
1210 true,
1211 Box::new(move |session, done| {
1212 match done {
1213 Ok(Done::Uids(uids)) => {
1214 session.body_hits.insert(term, uids.into_iter().collect());
1215 }
1216 _ => {
1217 session.body_local.insert(term);
1218 }
1219 }
1220 redo(session);
1221 }),
1222 );
1223 false
1224 }
1225
1226 pub fn rescan(&mut self) {
1229 let Ok(files) = maildir::scan(&self.dir) else {
1230 return;
1231 };
1232 let keep = self.selected_path();
1233 let mut old: std::collections::HashMap<PathBuf, Msg> = self
1234 .msgs
1235 .drain(..)
1236 .map(|m| (m.env.file.path.clone(), m))
1237 .collect();
1238 let mut arrived = 0usize;
1239 let mut arrivals: Vec<PathBuf> = Vec::new();
1240 for file in files {
1241 match old.remove(&file.path) {
1242 Some(prev) if prev.pending() => self.msgs.push(prev),
1243 Some(mut prev) => {
1244 prev.env.file = file;
1246 self.msgs.push(prev);
1247 }
1248 None => {
1249 if let Ok(env) = message::envelope(file) {
1250 if env.file.is_new {
1251 arrived += 1;
1252 arrivals.push(env.file.path.clone());
1253 }
1254 self.msgs.push(Msg {
1255 env,
1256 dirty: false,
1257 purge: false,
1258 });
1259 }
1260 }
1261 }
1262 }
1263 self.dir_mtimes = dir_mtimes(&self.dir);
1264 self.resort(keep.clone());
1265 if self.sort == SortKey::Threads
1268 && self.config.index.uncollapse_new.unwrap_or(true)
1269 && !self.collapsed.is_empty()
1270 {
1271 let mut unfolded = false;
1272 for path in &arrivals {
1273 if let Some(mi) = self.msgs.iter().position(|m| &m.env.file.path == path) {
1274 let root = self.thread_root.get(mi).copied().unwrap_or(mi);
1275 unfolded |= self.collapsed.remove(&self.msgs[root].env.file.path);
1276 }
1277 }
1278 if unfolded {
1279 self.rebuild_visible(keep);
1280 }
1281 }
1282 if arrived > 0 {
1283 let title = self.title.clone();
1284 self.notify(Notice::NewMail(format!("new mail in {title} (+{arrived})")));
1285 self.run_new_mail_command(&title, arrived);
1286 }
1287 }
1288
1289 fn run_new_mail_command(&self, mailbox: &str, count: usize) {
1292 let Some(cmd) = &self.config.mail.new_mail_command else {
1293 return;
1294 };
1295 let cmd = cmd
1296 .replace("%f", &format!("'{}'", mailbox.replace('\'', r"'\''")))
1297 .replace("%n", &count.to_string());
1298 let _ = Command::new("sh")
1299 .arg("-c")
1300 .arg(cmd)
1301 .stdout(Stdio::null())
1302 .stderr(Stdio::null())
1303 .spawn();
1304 }
1305
1306 pub fn check_new_mail(&mut self) {
1313 if self.pending.is_some() {
1316 return;
1317 }
1318 let backfilling = self.backfill.as_ref().is_some_and(|b| !b.done());
1319 if !backfilling && self.start(Job::CheckNew, Pending::CheckNew) {
1323 return;
1324 }
1325 self.check_local_mail();
1326 }
1327
1328 fn check_local_mail(&mut self) {
1331 self.maybe_backfill();
1332 if let Some(mbox) = &mut self.mbox {
1333 if let Err(err) = mbox.refresh() {
1335 self.error(format!("mbox: {err:#}"));
1336 }
1337 }
1338 self.check_other_mailboxes();
1339 self.requests.push(Request::MailboxesChanged);
1341 let check_new = self.config.mail.check_new.unwrap_or(true) || self.imap.is_some();
1345 if check_new && dir_mtimes(&self.dir) != self.dir_mtimes {
1346 self.rescan();
1347 }
1348 if self.deferred.is_empty() {
1351 self.refresh_unseen();
1352 }
1353 }
1354
1355 pub fn poll_network(&mut self) {
1359 self.poll_opening();
1360 if let Some(line) = self.imap.as_mut().and_then(Imap::take_progress) {
1361 if !self.aborted {
1364 self.note_progress(format!("{line} (Ctrl+G aborts)"));
1365 }
1366 }
1367 let Some(done) = self.imap.as_mut().and_then(Imap::collect) else {
1368 return;
1369 };
1370 if mem::take(&mut self.progress_noted) {
1373 self.clear_notice();
1374 }
1375 if let Some(pending) = self.pending.take() {
1376 self.resume(pending, done);
1377 }
1378 while self.pending.is_none()
1381 && let Some(next) = self.deferred.pop_front()
1382 {
1383 match next {
1384 Deferred::Again(again) => self.run_again(again),
1385 Deferred::Job(job, pending) => {
1386 self.start(job, pending);
1387 }
1388 }
1389 }
1390 }
1391
1392 fn poll_opening(&mut self) {
1395 let Some(opening) = &self.opening else {
1396 return;
1397 };
1398 let line = opening
1399 .progress
1400 .lock()
1401 .ok()
1402 .and_then(|mut slot| slot.take());
1403 let answer = match opening.answer.try_recv() {
1404 Ok(answer) => answer,
1405 Err(TryRecvError::Empty) => {
1406 if let Some(line) = line {
1407 self.note_progress(format!("{line} (Ctrl+G aborts)"));
1408 }
1409 return;
1410 }
1411 Err(TryRecvError::Disconnected) => Err(anyhow::anyhow!("the connection is gone")),
1412 };
1413 let Some(opening) = self.opening.take() else {
1414 return;
1415 };
1416 if mem::take(&mut self.progress_noted) {
1417 self.clear_notice();
1418 }
1419 match answer {
1420 Ok((next, warnings)) => self.become_(next, warnings, opening.read_only),
1421 Err(err) => self.error(format!("cannot open {}: {err:#}", opening.spec)),
1422 }
1423 }
1424
1425 pub fn busy(&self) -> Option<&'static str> {
1427 if self.opening.is_some() {
1428 return Some("opening the folder");
1429 }
1430 self.imap.as_ref().and_then(Imap::busy)
1431 }
1432
1433 pub fn abort_network(&mut self) {
1437 let Some(what) = self.busy() else {
1438 return;
1439 };
1440 if self.opening.take().is_some() {
1441 self.note(format!("aborted: {what}"));
1444 self.progress_noted = false;
1445 return;
1446 }
1447 if let Some(imap) = &self.imap {
1448 imap.abort();
1449 }
1450 self.note(format!("aborted: {what}"));
1451 self.deferred.retain(Deferred::outlives_abort);
1454 self.listing = false;
1455 self.progress_noted = false;
1458 self.aborted = true;
1459 }
1460
1461 fn start(&mut self, job: Job, pending: Pending) -> bool {
1468 if self.imap.is_none() {
1469 return false;
1470 }
1471 if self.pending.is_some() {
1472 self.deferred.push_back(Deferred::Job(job, pending));
1473 return true;
1474 }
1475 let Some(imap) = &mut self.imap else {
1476 return false;
1477 };
1478 let what = job.what();
1479 match imap.start(job) {
1480 Ok(()) => {
1481 self.note_progress(format!("{what}... (Ctrl+G aborts)"));
1485 self.pending = Some(pending);
1486 true
1487 }
1488 Err(err) => {
1489 self.error(format!("imap: {err:#}"));
1490 false
1491 }
1492 }
1493 }
1494
1495 fn note_progress(&mut self, line: String) {
1499 if self.progress_noted || !self.spoken {
1500 self.note(line);
1501 self.spoken = false;
1502 self.progress_noted = true;
1503 }
1504 }
1505
1506 fn send_then(&mut self, job: Job, hold: bool, then: Then) {
1510 if self.imap.is_none() {
1511 then(self, Err(anyhow::anyhow!("not connected")));
1512 return;
1513 }
1514 self.start(job, Pending::Then { hold, then });
1517 }
1518
1519 fn settle(&mut self) {
1522 while let Some(pending) = self.pending.take() {
1523 let Some(imap) = &mut self.imap else { break };
1524 let done = imap.wait();
1525 if mem::take(&mut self.progress_noted) {
1526 self.clear_notice();
1527 }
1528 self.resume(pending, done);
1529 }
1530 }
1531
1532 fn resume(&mut self, pending: Pending, done: Result<Done>) {
1533 if mem::take(&mut self.aborted) && done.is_err() {
1534 return;
1537 }
1538 match pending {
1539 Pending::CheckNew => {
1540 if let Err(err) = done {
1541 self.error(format!("imap: {err:#}"));
1542 }
1543 self.check_local_mail();
1544 }
1545 Pending::Sync { purge } => match done {
1546 Err(err) => self.error(format!("sync failed: {err:#}")),
1548 Ok(_) => self.finish_sync(purge),
1549 },
1550 Pending::Counts(specs) => {
1551 if let Ok(Done::Counts(counts)) = done {
1552 for (spec, count) in specs.into_iter().zip(counts) {
1553 self.unseen.insert(spec, count);
1554 }
1555 self.requests.push(Request::MailboxesChanged);
1556 }
1557 }
1558 Pending::Again(again) => match done {
1559 Err(err) => self.error(format!("cannot fetch message: {err:#}")),
1560 Ok(_) => self.run_again(again),
1561 },
1562 Pending::Then { then, .. } => then(self, done),
1563 }
1564 }
1565
1566 fn run_again(&mut self, again: Again) {
1569 match again {
1570 Again::View => self.open_message(),
1571 Again::Copy {
1572 input,
1573 delete,
1574 tagged,
1575 decode,
1576 } => self.copy_message(&input, delete, tagged, decode),
1577 Again::Pipe { command, tagged } => self.pipe_message(&command, tagged),
1578 Again::Print { tagged } => self.print_current(tagged),
1579 Again::Bounce { to, tagged } => self.bounce_current(&to, tagged),
1580 }
1581 }
1582
1583 fn have_bodies(&mut self, paths: &[PathBuf], again: Again) -> bool {
1590 if self.imap.is_none() {
1591 return true;
1592 }
1593 let missing: Vec<PathBuf> = paths
1594 .iter()
1595 .filter(|path| remote::is_partial(path))
1596 .cloned()
1597 .collect();
1598 if missing.is_empty() {
1599 return true;
1600 }
1601 if self.pending.is_some() {
1605 self.deferred.push_back(Deferred::Again(again));
1606 return false;
1607 }
1608 !self.start(Job::FetchBodies(missing), Pending::Again(again))
1609 }
1610
1611 fn target_paths(&self, tagged: bool) -> Vec<PathBuf> {
1614 self.op_targets(tagged)
1615 .into_iter()
1616 .map(|i| self.msgs[i].env.file.path.clone())
1617 .collect()
1618 }
1619
1620 pub fn idle_kick(&self) -> bool {
1623 self.idle.as_ref().is_some_and(|w| w.take_changed())
1624 }
1625
1626 fn check_other_mailboxes(&mut self) {
1629 let mut grew: Vec<String> = Vec::new();
1630 for spec in self.config.mail.mailboxes.clone() {
1631 if spec.starts_with("imap:") || spec == self.title {
1632 continue; }
1634 let dir = expand_tilde(&spec);
1635 if dir == self.dir || !dir.join("new").is_dir() {
1636 continue;
1637 }
1638 let count = maildir::new_count(&dir);
1639 let prev = self.mailbox_new.insert(spec.clone(), count);
1640 if let Some(p) = prev.filter(|&p| count > p) {
1641 self.run_new_mail_command(&spec, count - p);
1642 grew.push(spec);
1643 } else if !self.config.mail.mail_check_recent.unwrap_or(true) {
1644 if count == 0 {
1648 self.mailbox_announced.remove(&spec);
1649 } else if self.mailbox_announced.insert(spec.clone()) {
1650 grew.push(spec);
1651 }
1652 }
1653 }
1654 if !grew.is_empty() && self.notice().is_none() {
1656 self.notify(Notice::NewMail(format!("new mail in {}", grew.join(", "))));
1657 }
1658 }
1659
1660 pub fn maybe_backfill(&mut self) {
1663 if self.backfill.as_ref().is_some_and(|b| !b.done()) {
1664 return;
1665 }
1666 self.backfill = None;
1667 let Some(imap) = &mut self.imap else {
1668 return;
1669 };
1670 if imap.facts.pending_backfill.is_empty() {
1671 return;
1672 }
1673 let uids = imap.take_backfill();
1674 let facts = imap.facts.clone();
1675 let Ok(password) = account_password(&facts.account) else {
1676 return;
1677 };
1678 let count = uids.len();
1679 self.backfill = Some(remote::backfill(
1680 &facts.account,
1681 &facts.mailbox,
1682 &password,
1683 facts.cache,
1684 uids,
1685 ));
1686 self.note(format!(
1687 "loading {count} older message(s) in the background"
1688 ));
1689 }
1690
1691 pub fn error_history(&self) -> Vec<String> {
1695 self.error_history.iter().cloned().collect()
1696 }
1697
1698 pub fn next_unread_mailbox(&self) -> Option<String> {
1703 let boxes = &self.config.mail.mailboxes;
1704 let start = boxes
1705 .iter()
1706 .position(|spec| *spec == self.title || expand_tilde(spec) == self.dir)
1707 .map(|i| i + 1)
1708 .unwrap_or(0);
1709 (0..boxes.len())
1710 .map(|k| &boxes[(start + k) % boxes.len()])
1711 .filter(|spec| **spec != self.title && expand_tilde(spec) != self.dir)
1712 .find(|spec| {
1713 if spec.starts_with("imap:") {
1714 self.unseen.get(*spec).copied().unwrap_or(0) > 0
1715 } else {
1716 maildir::new_count(&expand_tilde(spec)) > 0
1717 }
1718 })
1719 .cloned()
1720 }
1721
1722 pub fn delete_rules(&self) -> DeleteRules {
1725 DeleteRules {
1726 flag_safe: self.config.mail.flag_safe,
1727 untag: self.config.mail.delete_untag.unwrap_or(true),
1728 }
1729 }
1730
1731 pub fn each_tagged(&mut self, what: &str, f: impl Fn(&mut Msg)) {
1732 let tagged: Vec<usize> = (0..self.msgs.len())
1733 .filter(|&i| self.msgs[i].env.tagged)
1734 .collect();
1735 self.push_undo(what, &tagged);
1736 for &i in &tagged {
1737 let m = &mut self.msgs[i];
1738 f(m);
1739 m.dirty = true;
1740 }
1741 self.note(format!("applied to {} tagged message(s)", tagged.len()));
1742 }
1743
1744 pub fn op_targets(&self, tagged: bool) -> Vec<usize> {
1752 if tagged {
1753 return (0..self.msgs.len())
1754 .filter(|&i| self.msgs[i].env.tagged)
1755 .collect();
1756 }
1757 self.visible.get(self.sel).copied().into_iter().collect()
1758 }
1759
1760 pub fn push_undo(&mut self, what: &str, indices: &[usize]) {
1763 if indices.is_empty() {
1764 return;
1765 }
1766 let marks = indices.iter().map(|&i| self.mark(i)).collect();
1767 self.push_undo_step(UndoStep {
1768 what: what.to_string(),
1769 marks,
1770 sel: self.selected_path(),
1771 created: Vec::new(),
1772 note: None,
1773 rewritten: Vec::new(),
1774 });
1775 }
1776
1777 pub fn push_undo_step(&mut self, step: UndoStep) {
1778 self.undo.push(step);
1779 while self.undo.len() > UNDO_MAX_STEPS
1781 || (self.undo.len() > 1
1782 && self.undo.iter().map(|s| s.marks.len()).sum::<usize>() > UNDO_MAX_MARKS)
1783 {
1784 self.undo.remove(0);
1785 }
1786 }
1787
1788 pub fn mark(&self, i: usize) -> MsgMark {
1789 let m = &self.msgs[i];
1790 MsgMark {
1791 path: m.env.file.path.clone(),
1792 flags: m.env.file.flags,
1793 is_new: m.env.file.is_new,
1794 tagged: m.env.tagged,
1795 dirty: m.dirty,
1796 }
1797 }
1798
1799 pub fn undo_last(&mut self) {
1805 let Some(step) = self.undo.pop() else {
1806 self.note("nothing to undo");
1807 return;
1808 };
1809 let mut restored = 0usize;
1810 let mut rewrite_failed = Vec::new();
1811 let mut resort = false;
1812 for (path, bytes) in &step.rewritten {
1813 let at = self.msgs.iter().position(|m| &m.env.file.path == path);
1814 let put_back = std::fs::write(path, bytes)
1815 .map_err(|err| anyhow::anyhow!(err))
1816 .and_then(|()| match at {
1817 Some(i) => self.reread(i, bytes.len() as u64),
1818 None => Ok(()),
1819 });
1820 match put_back {
1821 Ok(()) => resort = true,
1822 Err(err) => rewrite_failed.push(format!("{}: {err}", path.display())),
1823 }
1824 }
1825 if resort {
1826 let keep = step.sel.clone();
1827 self.resort(keep);
1828 }
1829 let by_path: HashMap<&Path, usize> = self
1830 .msgs
1831 .iter()
1832 .enumerate()
1833 .map(|(i, m)| (m.env.file.path.as_path(), i))
1834 .collect();
1835 let mut wanted: Vec<(usize, MsgMark)> = Vec::new();
1836 for mark in &step.marks {
1837 if let Some(&i) = by_path.get(mark.path.as_path()) {
1838 wanted.push((i, mark.clone()));
1839 }
1840 }
1841 for (i, mark) in wanted {
1842 let m = &mut self.msgs[i];
1843 m.env.file.flags = mark.flags;
1844 m.env.file.is_new = mark.is_new;
1845 m.env.tagged = mark.tagged;
1846 m.dirty = mark.dirty;
1847 restored += 1;
1848 }
1849 let mut failed = rewrite_failed;
1850 for path in &step.created {
1851 if let Err(err) = std::fs::remove_file(path) {
1852 failed.push(format!("{}: {err}", path.display()));
1853 }
1854 }
1855 if let Some(vi) = step.sel.and_then(|p| {
1856 self.visible
1857 .iter()
1858 .position(|&i| self.msgs[i].env.file.path == p)
1859 }) {
1860 self.sel = vi;
1861 }
1862 let mut note = format!("undone: {} ({restored} message(s))", step.what);
1863 if let Some(extra) = &step.note {
1864 note += &format!("; {extra}");
1865 }
1866 if !failed.is_empty() {
1867 self.error(format!("{note}; could not remove {}", failed.join("; ")));
1868 } else {
1869 self.note(note);
1870 }
1871 }
1872
1873 pub fn message_bytes(&mut self, i: usize) -> Option<Vec<u8>> {
1876 self.settle();
1880 let path = self.msgs.get(i)?.env.file.path.clone();
1881 if remote::is_partial(&path)
1882 && let Some(imap) = &mut self.imap
1883 && let Err(err) = imap.blocking(Job::FetchBodies(vec![path.clone()]))
1884 {
1885 self.error(format!("cannot fetch message: {err:#}"));
1886 return None;
1887 }
1888 match std::fs::read(&path) {
1889 Ok(bytes) => Some(bytes),
1890 Err(err) => {
1891 self.error(format!("cannot read message: {err}"));
1892 None
1893 }
1894 }
1895 }
1896
1897 pub fn full_message_bytes(&mut self) -> Option<Vec<u8>> {
1898 let &i = self.visible.get(self.sel)?;
1899 self.message_bytes(i)
1900 }
1901
1902 pub fn op_bytes(&mut self, tagged: bool) -> Option<Vec<u8>> {
1904 let mut out = Vec::new();
1905 for i in self.op_targets(tagged) {
1906 let bytes = self.message_bytes(i)?;
1907 out.extend_from_slice(&bytes);
1908 if !bytes.ends_with(b"\n") {
1909 out.push(b'\n');
1910 }
1911 }
1912 Some(out)
1913 }
1914
1915 pub fn rebuild_visible(&mut self, keep: Option<PathBuf>) {
1918 let mut visible = Vec::with_capacity(self.msgs.len());
1919 let positions = self.positions();
1920 for (i, pos) in positions.iter().enumerate() {
1921 let limit_ok = match &self.limit {
1922 Some((_, patterns)) => self.env_matches_at(patterns, &self.msgs[i].env, *pos, i),
1923 None => true,
1924 };
1925 if !limit_ok {
1926 continue;
1927 }
1928 if self.limit.is_none()
1929 && self.sort == SortKey::Threads
1930 && self.thread_depth.get(i).copied().unwrap_or(0) > 0
1931 {
1932 let root = self.thread_root.get(i).copied().unwrap_or(i);
1933 if self.collapsed.contains(&self.msgs[root].env.file.path) {
1934 continue;
1935 }
1936 }
1937 visible.push(i);
1938 }
1939 self.visible = visible;
1940 self.sel = keep
1941 .and_then(|p| {
1942 self.visible
1943 .iter()
1944 .position(|&i| self.msgs[i].env.file.path == p)
1945 })
1946 .unwrap_or_else(|| self.visible.len().saturating_sub(1));
1947 }
1948
1949 pub fn apply_sort(&mut self) {
1950 let keep = self.selected_path();
1951 self.resort(keep);
1952 }
1953
1954 fn resort(&mut self, keep: Option<PathBuf>) {
1955 if self.sort == SortKey::Threads {
1956 let order = self
1959 .config
1960 .index
1961 .sort_aux
1962 .as_deref()
1963 .map(thread::ThreadOrder::parse)
1964 .unwrap_or_default();
1965 let items = {
1970 let fallback = (!self.config.index.strict_threads.unwrap_or(false)).then(|| {
1971 thread::SubjectFallback {
1972 reply_re: &self.reply_re,
1973 sort_re: self.config.index.sort_re.unwrap_or(true),
1974 }
1975 });
1976 let envs: Vec<&Envelope> = self.msgs.iter().map(|m| &m.env).collect();
1977 thread::thread_with(&envs, order, fallback.as_ref())
1978 };
1979 let mut old: Vec<Option<Msg>> = self.msgs.drain(..).map(Some).collect();
1980 let mut new_pos = vec![0usize; old.len()];
1981 for (pos, item) in items.iter().enumerate() {
1982 new_pos[item.index] = pos;
1983 }
1984 self.msgs = items
1985 .iter()
1986 .map(|item| {
1987 old[item.index]
1988 .take()
1989 .expect("thread order is a permutation")
1990 })
1991 .collect();
1992 self.thread_depth = items.iter().map(|item| item.depth).collect();
1993 self.thread_root = items.iter().map(|item| new_pos[item.root]).collect();
1994 self.thread_pseudo = items.iter().map(|item| item.pseudo).collect();
1995 self.sort_rev = false;
1996 self.index_threads();
1997 } else {
1998 let (sort, rev) = (self.sort, self.sort_rev);
1999 self.msgs.sort_by(|a, b| {
2000 let ord = match sort {
2001 SortKey::Date => a.env.date.cmp(&b.env.date),
2002 SortKey::From => a.env.from.to_lowercase().cmp(&b.env.from.to_lowercase()),
2003 SortKey::Subject => {
2004 subject_key(&a.env.subject).cmp(&subject_key(&b.env.subject))
2005 }
2006 SortKey::Size => a.env.file.size.cmp(&b.env.file.size),
2007 SortKey::Label => {
2008 let key = |e: &Envelope| {
2010 let l = e.label.clone().unwrap_or_default().to_lowercase();
2011 (l.is_empty(), l)
2012 };
2013 key(&a.env).cmp(&key(&b.env))
2014 }
2015 SortKey::To => {
2016 let first_to =
2017 |e: &Envelope| e.to.first().cloned().unwrap_or_default().to_lowercase();
2018 first_to(&a.env).cmp(&first_to(&b.env))
2019 }
2020 SortKey::Unsorted => a
2021 .env
2022 .file
2023 .path
2024 .file_name()
2025 .cmp(&b.env.file.path.file_name()),
2026 SortKey::Threads => unreachable!(),
2027 };
2028 if rev { ord.reverse() } else { ord }
2029 });
2030 self.thread_depth = vec![0; self.msgs.len()];
2031 self.thread_root = (0..self.msgs.len()).collect();
2032 self.thread_pseudo = vec![false; self.msgs.len()];
2033 self.index_threads();
2034 }
2035 self.rebuild_visible(keep);
2036 }
2037
2038 fn index_threads(&mut self) {
2041 let n = self.msgs.len();
2042 let mut parent = vec![None; n];
2043 let mut children = vec![Vec::new(); n];
2044 let mut members: Vec<Vec<usize>> = vec![Vec::new(); n];
2045 let mut last_at_depth: Vec<usize> = Vec::new();
2046 #[allow(clippy::needless_range_loop)]
2047 for i in 0..n {
2048 let depth = self.thread_depth.get(i).copied().unwrap_or(0);
2049 last_at_depth.truncate(depth);
2050 if let Some(&p) = last_at_depth.last() {
2051 parent[i] = Some(p);
2052 children[p].push(i);
2053 }
2054 last_at_depth.push(i);
2055 let root = self.thread_root.get(i).copied().unwrap_or(i);
2056 members[root].push(i);
2057 }
2058 self.thread_parent = parent;
2059 self.thread_children = children;
2060 self.thread_members = members;
2061 }
2062
2063 pub fn toggle_collapse(&mut self, all: bool) {
2064 if self.sort != SortKey::Threads {
2065 self.error("folding needs thread sort (o t)");
2066 return;
2067 }
2068 let mut keep;
2069 if all {
2070 keep = self.selected_path();
2071 if self.collapsed.is_empty() {
2072 let fold_unread = self.config.index.collapse_unread.unwrap_or(true);
2075 self.collapsed = (0..self.msgs.len())
2076 .filter(|&i| self.thread_depth.get(i) == Some(&0))
2077 .filter(|&i| fold_unread || !self.thread_has_unread(i))
2078 .map(|i| self.msgs[i].env.file.path.clone())
2079 .collect();
2080 } else {
2081 self.collapsed.clear();
2082 }
2083 } else {
2084 let Some(&mi) = self.visible.get(self.sel) else {
2085 return;
2086 };
2087 let root = self.thread_root.get(mi).copied().unwrap_or(mi);
2088 let path = self.msgs[root].env.file.path.clone();
2089 let unfolding = self.collapsed.remove(&path);
2090 if !unfolding {
2091 self.collapsed.insert(path.clone());
2092 }
2093 keep = Some(path);
2094 if unfolding && self.config.index.uncollapse_jump {
2096 keep = self.first_unread_in_thread(root).or(keep);
2097 }
2098 }
2099 self.rebuild_visible(keep);
2100 }
2101
2102 pub fn step_message(&self, forward: bool, skip_deleted: bool) -> Option<usize> {
2105 let mut pos = self.sel;
2106 loop {
2107 pos = if forward {
2108 pos + 1
2109 } else {
2110 pos.checked_sub(1)?
2111 };
2112 let &i = self.visible.get(pos)?;
2113 if !skip_deleted || !self.msgs[i].env.file.flags.deleted {
2114 return Some(pos);
2115 }
2116 }
2117 }
2118
2119 fn thread_targets(&mut self, sub: bool) -> Option<Vec<usize>> {
2127 if self.sort != SortKey::Threads {
2128 self.error("thread operations need thread sort (o t)");
2129 return None;
2130 }
2131 let &mi = self.visible.get(self.sel)?;
2132 if !sub {
2133 let root = self.thread_root.get(mi).copied().unwrap_or(mi);
2134 return Some(
2135 (0..self.msgs.len())
2136 .filter(|&i| self.thread_root.get(i).copied().unwrap_or(i) == root)
2137 .collect(),
2138 );
2139 }
2140 let depth = self.thread_depth.get(mi).copied().unwrap_or(0);
2143 let mut out = vec![mi];
2144 for i in (mi + 1)..self.msgs.len() {
2145 if self.thread_depth.get(i).copied().unwrap_or(0) <= depth {
2146 break;
2147 }
2148 out.push(i);
2149 }
2150 Some(out)
2151 }
2152
2153 pub fn thread_mark(&mut self, sub: bool, op: ThreadOp) {
2157 if op != ThreadOp::Tag && self.deny_readonly() {
2158 return;
2159 }
2160 let Some(&mi) = self.visible.get(self.sel) else {
2161 return;
2162 };
2163 let Some(targets) = self.thread_targets(sub) else {
2164 return;
2165 };
2166 let scope = if sub { "subthread" } else { "thread" };
2167 let (what, verb) = match op {
2168 ThreadOp::Delete => (format!("delete {scope}"), "deleted"),
2169 ThreadOp::Undelete => (format!("undelete {scope}"), "undeleted"),
2170 ThreadOp::Read => (format!("read {scope}"), "marked read"),
2171 ThreadOp::Tag if self.msgs[mi].env.tagged => (format!("untag {scope}"), "untagged"),
2174 ThreadOp::Tag => (format!("tag {scope}"), "tagged"),
2175 };
2176 self.push_undo(&what, &targets);
2177 let want_tag = !self.msgs[mi].env.tagged;
2178 let rules = self.delete_rules();
2179 for &i in &targets {
2180 match op {
2181 ThreadOp::Delete => {
2182 if rules.mark(&mut self.msgs[i]) {
2183 self.msgs[i].dirty = true;
2184 }
2185 }
2186 ThreadOp::Undelete => {
2187 self.msgs[i].env.file.flags.deleted = false;
2188 self.msgs[i].dirty = true;
2189 }
2190 ThreadOp::Tag => self.msgs[i].env.tagged = want_tag,
2191 ThreadOp::Read => {
2192 let m = &mut self.msgs[i];
2193 if !m.env.file.flags.seen || m.env.file.is_new {
2194 m.env.file.flags.seen = true;
2195 m.env.file.is_new = false;
2196 m.dirty = true;
2197 }
2198 }
2199 }
2200 }
2201 self.note(format!("{} {verb}", targets.len()));
2202 if op == ThreadOp::Delete
2205 && let Some(pos) = self.step_message(true, true)
2206 {
2207 self.sel = pos;
2208 }
2209 }
2210
2211 pub fn jump_parent(&mut self, root: bool) {
2215 if self.sort != SortKey::Threads {
2216 self.error("thread operations need thread sort (o t)");
2217 return;
2218 }
2219 let Some(&mi) = self.visible.get(self.sel) else {
2220 return;
2221 };
2222 let target = if root {
2223 self.thread_root.get(mi).copied().filter(|&r| r != mi)
2224 } else {
2225 self.thread_parent.get(mi).copied().flatten()
2226 };
2227 let Some(target) = target else {
2228 self.error(if root {
2229 "already the thread root"
2230 } else {
2231 "no parent message"
2232 });
2233 return;
2234 };
2235 let tr = self.thread_root.get(target).copied().unwrap_or(target);
2236 let at = self
2237 .visible
2238 .iter()
2239 .position(|&i| i == target)
2240 .or_else(|| self.visible.iter().position(|&i| i == tr));
2241 match at {
2242 Some(vi) => self.select(vi),
2243 None => self.error("the parent is not in the current limit"),
2244 }
2245 }
2246
2247 fn can_rewrite(&mut self) -> bool {
2252 if self.deny_readonly() {
2253 return false;
2254 }
2255 if self.sort != SortKey::Threads {
2256 self.error("thread operations need thread sort (o t)");
2257 return false;
2258 }
2259 if self.imap.is_some() || self.mbox.is_some() {
2260 self.error("rewriting a message's threading is for local maildirs only");
2261 return false;
2262 }
2263 true
2264 }
2265
2266 fn rewrite_thread(
2272 &mut self,
2273 i: usize,
2274 in_reply_to: Option<&str>,
2275 references: &[String],
2276 broken: bool,
2277 ) -> Result<Vec<u8>> {
2278 let path = self.msgs[i].env.file.path.clone();
2279 let old = std::fs::read(&path).with_context(|| format!("reading {}", path.display()))?;
2280 let new = message::with_thread_headers(&old, in_reply_to, references, broken);
2281 std::fs::write(&path, &new).with_context(|| format!("writing {}", path.display()))?;
2282 self.reread(i, new.len() as u64)?;
2283 Ok(old)
2284 }
2285
2286 fn reread(&mut self, i: usize, size: u64) -> Result<()> {
2289 let mut file = self.msgs[i].env.file.clone();
2290 file.size = size;
2291 let tagged = self.msgs[i].env.tagged;
2292 let mut env = message::envelope(file)?;
2293 env.tagged = tagged;
2294 self.msgs[i].env = env;
2295 Ok(())
2296 }
2297
2298 pub fn break_thread(&mut self) {
2303 let Some(&mi) = self.visible.get(self.sel) else {
2304 return;
2305 };
2306 if !self.can_rewrite() {
2307 return;
2308 }
2309 if self.msgs[mi].env.references.is_empty() && !self.subject_threaded(mi) {
2313 self.note("already a thread of its own");
2314 return;
2315 }
2316 let path = self.msgs[mi].env.file.path.clone();
2317 let mut step = UndoStep {
2318 what: "break thread".into(),
2319 marks: vec![self.mark(mi)],
2320 sel: Some(path.clone()),
2321 created: Vec::new(),
2322 note: None,
2323 rewritten: Vec::new(),
2324 };
2325 match self.rewrite_thread(mi, None, &[], true) {
2326 Ok(old) => {
2327 step.rewritten.push((path.clone(), old));
2328 self.push_undo_step(step);
2329 self.resort(Some(path));
2330 self.note("thread broken");
2331 }
2332 Err(err) => self.error(format!("break thread: {err:#}")),
2333 }
2334 }
2335
2336 pub fn link_threads(&mut self) {
2341 let Some(&mi) = self.visible.get(self.sel) else {
2342 return;
2343 };
2344 if !self.can_rewrite() {
2345 return;
2346 }
2347 let Some(parent_id) = self.msgs[mi].env.msg_id.clone() else {
2348 self.error("no Message-ID to link to");
2349 return;
2350 };
2351 let kids: Vec<usize> = (0..self.msgs.len())
2352 .filter(|&i| i != mi && self.msgs[i].env.tagged)
2353 .collect();
2354 if kids.is_empty() {
2355 self.error("first tag the message(s) to link");
2356 return;
2357 }
2358 let keep = self.msgs[mi].env.file.path.clone();
2359 let mut step = UndoStep {
2360 what: "link threads".into(),
2361 marks: kids.iter().map(|&i| self.mark(i)).collect(),
2362 sel: Some(keep.clone()),
2363 created: Vec::new(),
2364 note: None,
2365 rewritten: Vec::new(),
2366 };
2367 let mut linked = 0usize;
2368 for &i in &kids {
2369 let path = self.msgs[i].env.file.path.clone();
2370 match self.rewrite_thread(i, Some(&parent_id), &[], false) {
2371 Ok(old) => {
2372 step.rewritten.push((path, old));
2373 self.msgs[i].env.tagged = false;
2374 linked += 1;
2375 }
2376 Err(err) => self.error(format!("link threads: {err:#}")),
2377 }
2378 }
2379 if linked > 0 {
2380 self.push_undo_step(step);
2381 self.resort(Some(keep));
2382 self.note(format!("{linked} linked"));
2383 }
2384 }
2385
2386 pub fn edit_label(&mut self, value: &str, tagged: bool) {
2391 if !self.can_rewrite_here() {
2392 return;
2393 }
2394 let targets = self.op_targets(tagged);
2395 if targets.is_empty() {
2396 return;
2397 }
2398 let value = value.trim();
2399 let new = (!value.is_empty()).then(|| value.to_string());
2400 let keep = self.selected_path();
2401 let mut step = UndoStep {
2402 what: "edit label".into(),
2403 marks: targets.iter().map(|&i| self.mark(i)).collect(),
2404 sel: keep.clone(),
2405 created: Vec::new(),
2406 note: None,
2407 rewritten: Vec::new(),
2408 };
2409 let mut done = 0usize;
2410 for &i in &targets {
2411 let path = self.msgs[i].env.file.path.clone();
2412 let old = match std::fs::read(&path) {
2413 Ok(b) => b,
2414 Err(err) => {
2415 self.error(format!("edit label: {err}"));
2416 continue;
2417 }
2418 };
2419 let bytes = message::with_header(&old, "X-Label", new.as_deref());
2420 if let Err(err) = std::fs::write(&path, &bytes) {
2421 self.error(format!("edit label: {err}"));
2422 continue;
2423 }
2424 step.rewritten.push((path, old));
2425 let _ = self.reread(i, bytes.len() as u64);
2426 done += 1;
2427 }
2428 if done > 0 {
2429 self.push_undo_step(step);
2430 self.resort(keep);
2431 self.note(match new {
2432 Some(_) => format!("labelled {done} message(s)"),
2433 None => format!("label cleared on {done} message(s)"),
2434 });
2435 }
2436 }
2437
2438 fn can_rewrite_here(&mut self) -> bool {
2441 if self.deny_readonly() {
2442 return false;
2443 }
2444 if self.imap.is_some() || self.mbox.is_some() {
2445 self.error("editing a label is for local maildirs only");
2446 return false;
2447 }
2448 true
2449 }
2450
2451 pub fn subject_threaded(&self, mi: usize) -> bool {
2455 self.thread_pseudo.get(mi).copied().unwrap_or(false)
2456 }
2457
2458 pub fn subject_hidden(&self, mi: usize) -> bool {
2463 if !self.config.index.hide_thread_subject.unwrap_or(false) {
2464 return false;
2465 }
2466 let Some(parent) = self.thread_parent.get(mi).copied().flatten() else {
2467 return false;
2468 };
2469 match (self.msgs.get(mi), self.msgs.get(parent)) {
2470 (Some(m), Some(p)) => subject_key(&m.env.subject) == subject_key(&p.env.subject),
2471 _ => false,
2472 }
2473 }
2474
2475 pub fn toggle_write(&mut self) {
2479 if self.read_only_session {
2480 self.error("mailbox is read-only for the whole session (-R)");
2481 return;
2482 }
2483 self.read_only = !self.read_only;
2484 if self.read_only {
2485 self.note("mailbox marked read-only");
2486 } else {
2487 self.note("mailbox marked writable");
2488 }
2489 }
2490
2491 pub fn show_limit(&mut self) {
2494 match &self.limit {
2495 Some((raw, _)) => self.note(format!("limit: {raw}")),
2496 None => self.note("no limit pattern (all messages shown)"),
2497 }
2498 }
2499
2500 pub fn reply_subject(&self, orig: &str) -> String {
2502 compose::reply_subject(orig, &self.reply_re)
2503 }
2504
2505 pub fn jump_thread(&mut self, forward: bool) {
2508 if self.sort != SortKey::Threads {
2509 self.error("thread operations need thread sort (o t)");
2510 return;
2511 }
2512 for (vi, wrapped) in wrap_order(self.visible.len(), self.sel, forward) {
2513 let mi = self.visible[vi];
2514 if self.thread_depth.get(mi).copied().unwrap_or(0) == 0 {
2515 if wrapped {
2516 self.note("wrapped around");
2517 }
2518 self.select(vi);
2519 return;
2520 }
2521 }
2522 self.error("no other thread");
2523 }
2524
2525 pub fn jump_new(&mut self, forward: bool) {
2526 let n = self.visible.len();
2527 if n == 0 {
2528 return;
2529 }
2530 for (vi, wrapped) in wrap_order(n, self.sel, forward) {
2531 let m = &self.msgs[self.visible[vi]];
2532 if m.env.file.is_new || !m.env.file.flags.seen {
2533 if wrapped {
2534 self.note("search wrapped");
2535 }
2536 self.select(vi);
2537 return;
2538 }
2539 }
2540 self.error("no new or unread messages");
2541 }
2542
2543 pub fn compile_search(&self, input: &str) -> Result<Vec<Pattern>, String> {
2551 pattern::parse(&self.expand_simple(input))
2552 }
2553
2554 fn expand_simple(&self, input: &str) -> String {
2555 let word = input.trim();
2556 if word.contains('~') || word.split_whitespace().count() != 1 {
2557 return input.to_string();
2558 }
2559 let template = self
2560 .config
2561 .mail
2562 .simple_search
2563 .as_deref()
2564 .unwrap_or("~f %s | ~s %s");
2565 template.replace("%s", word)
2566 }
2567
2568 pub fn apply_pattern(&mut self, input: &str, op: PatternOp) {
2569 let flag_safe = self.config.mail.flag_safe;
2570 let verb = op.verb();
2571 if input.is_empty() {
2572 return;
2573 }
2574 let patterns = match self.compile_search(input) {
2575 Ok(p) => p,
2576 Err(err) => {
2577 self.error(format!("bad pattern: {err}"));
2578 return;
2579 }
2580 };
2581 let redo_input = input.to_string();
2582 if !self.body_terms_ready(
2583 &patterns,
2584 Box::new(move |session| session.apply_pattern(&redo_input, op)),
2585 ) {
2586 return;
2587 }
2588 let positions = self.positions();
2589 let mut hits = Vec::new();
2590 for (i, pos) in positions.iter().enumerate() {
2591 let in_limit = match &self.limit {
2592 Some((_, l)) => self.env_matches_at(l, &self.msgs[i].env, *pos, i),
2593 None => true,
2594 };
2595 if in_limit && self.env_matches_at(&patterns, &self.msgs[i].env, *pos, i) {
2596 hits.push(i);
2597 }
2598 }
2599 self.push_undo(&format!("{verb} by pattern"), &hits);
2600 for &i in &hits {
2601 op.apply(&mut self.msgs[i], flag_safe);
2602 }
2603 self.note(format!("{} {verb}", hits.len()));
2604 }
2605
2606 pub fn search_next(&mut self) {
2607 let Some(patterns) = self.last_search.clone() else {
2608 self.error("no search pattern (use /)");
2609 return;
2610 };
2611 if self.visible.is_empty() {
2612 return;
2613 }
2614 let wrap = self.config.mail.wrap_search.unwrap_or(true);
2617 let positions = self.positions();
2618 for (vi, wrapped) in wrap_order(self.visible.len(), self.sel, !self.search_rev) {
2619 if wrapped && !wrap {
2620 break;
2621 }
2622 let mi = self.visible[vi];
2623 if self.env_matches_at(&patterns, &self.msgs[mi].env, positions[mi], mi) {
2624 if wrapped {
2625 self.note("search wrapped");
2626 }
2627 self.sel = vi;
2628 return;
2629 }
2630 }
2631 self.error("not found");
2632 }
2633
2634 fn trash_deleted(&mut self, trash: &str) -> Result<()> {
2638 self.settle();
2642 let deleted: Vec<PathBuf> = self
2644 .msgs
2645 .iter()
2646 .filter(|m| m.env.file.flags.deleted && !m.purge)
2647 .map(|m| m.env.file.path.clone())
2648 .collect();
2649 match (remote::parse_spec(trash), &mut self.imap) {
2650 (Some((account, folder)), Some(imap)) if imap.facts.account.name == account => imap
2651 .blocking(Job::CopyToFolder {
2652 paths: deleted,
2653 mailbox: folder.to_string(),
2654 })
2655 .map(|_| ()),
2656 (Some(_), _) => anyhow::bail!("trash must be a folder of the open account"),
2657 (None, Some(_)) => {
2658 anyhow::bail!("an IMAP mailbox needs an imap:account/folder trash")
2659 }
2660 (None, None) => {
2661 let dir = expand_tilde(trash);
2662 maildir::create(&dir)?;
2663 for m in self
2664 .msgs
2665 .iter()
2666 .filter(|m| m.env.file.flags.deleted && !m.purge)
2667 {
2668 let bytes = std::fs::read(&m.env.file.path)?;
2669 let mut flags = m.env.file.flags;
2670 flags.deleted = false;
2671 maildir::deliver(&dir, &bytes, flags)?;
2672 }
2673 Ok(())
2674 }
2675 }
2676 }
2677
2678 pub fn sync(&mut self, purge: bool) {
2681 if self.deny_readonly() {
2682 return;
2683 }
2684 if purge
2687 && self.deleted_count() > 0
2688 && let Some(trash) = self.config.mail.trash.clone()
2689 && trash != self.title
2690 && expand_tilde(&trash) != self.dir
2691 && let Err(err) = self.trash_deleted(&trash)
2692 {
2693 self.error(format!("trash failed: {err:#}; nothing purged"));
2694 return;
2695 }
2696 if self.imap.is_some() {
2697 let mut deletes: Vec<PathBuf> = Vec::new();
2698 let mut flags: Vec<(PathBuf, maildir::Flags)> = Vec::new();
2699 for m in &self.msgs {
2700 if m.env.file.flags.deleted {
2701 if purge {
2702 deletes.push(m.env.file.path.clone());
2703 }
2704 } else if m.dirty {
2705 flags.push((m.env.file.path.clone(), m.env.file.flags));
2706 }
2707 }
2708 if self.start(Job::Sync { flags, deletes }, Pending::Sync { purge }) {
2712 return;
2713 }
2714 }
2715 self.finish_sync(purge);
2716 }
2717
2718 fn finish_sync(&mut self, purge: bool) {
2721 if let Some(mbox) = &mut self.mbox {
2722 let mut state: HashMap<String, Option<(maildir::Flags, bool)>> = HashMap::new();
2725 for m in &self.msgs {
2726 let Some(id) = mbox::id_of(&m.env.file.path) else {
2727 continue;
2728 };
2729 if m.env.file.flags.deleted && purge {
2730 state.insert(id, None);
2731 } else if m.pending() {
2732 let is_new = m.env.file.is_new && !m.dirty;
2733 state.insert(id, Some((m.env.file.flags, is_new)));
2734 }
2735 }
2736 if let Err(err) = mbox.write_back(&state) {
2737 self.error(format!("sync failed: {err:#}"));
2739 return;
2740 }
2741 }
2742 let keep = self.selected_path();
2743 let mut removed = 0usize;
2744 let mut saved = 0usize;
2745 let mut errors: Vec<String> = Vec::new();
2746 let trash = self.config.mail.maildir_trash && self.imap.is_none() && self.mbox.is_none();
2750 self.msgs.retain_mut(|m| {
2751 if m.env.file.flags.deleted {
2752 if !purge {
2753 return true; }
2755 if trash {
2756 if m.dirty {
2757 match maildir::store_flags(&m.env.file) {
2758 Ok(path) => {
2759 m.env.file.path = path;
2760 m.env.file.is_new = false;
2761 m.dirty = false;
2762 saved += 1;
2763 }
2764 Err(err) => errors.push(err.to_string()),
2765 }
2766 }
2767 return true;
2768 }
2769 match maildir::remove(&m.env.file) {
2770 Ok(()) => {
2771 removed += 1;
2772 false
2773 }
2774 Err(err) => {
2775 errors.push(err.to_string());
2776 true
2777 }
2778 }
2779 } else {
2780 if m.dirty {
2781 match maildir::store_flags(&m.env.file) {
2782 Ok(path) => {
2783 m.env.file.path = path;
2784 m.env.file.is_new = false;
2785 m.dirty = false;
2786 saved += 1;
2787 }
2788 Err(err) => errors.push(err.to_string()),
2789 }
2790 }
2791 true
2792 }
2793 });
2794 self.dir_mtimes = dir_mtimes(&self.dir);
2795 self.undo.clear();
2798 self.resort(keep);
2799 match errors.is_empty() {
2800 true => self.notify(Notice::Synced {
2801 deleted: removed,
2802 updated: saved,
2803 }),
2804 false => self.note(format!("sync errors: {}", errors.join("; "))),
2805 }
2806 }
2807
2808 pub fn create_folder(&mut self, spec: &str) -> Result<String, String> {
2819 let id = self.manage(
2820 spec,
2821 |_account, folder| Manage::Create(folder),
2822 |path| {
2823 maildir::create(&path).map_err(|e| format!("{e:#}"))?;
2824 Ok(path.display().to_string())
2825 },
2826 )?;
2827 Ok(format!("created {id}"))
2828 }
2829
2830 pub fn delete_folder(&mut self, spec: &str) -> Result<String, String> {
2831 let id = self.manage(
2832 spec,
2833 |_account, folder| Manage::Delete(folder),
2834 |path| {
2835 std::fs::remove_dir_all(&path).map_err(|e| format!("{e}"))?;
2838 Ok(path.display().to_string())
2839 },
2840 )?;
2841 Ok(format!("deleted {id}"))
2842 }
2843
2844 pub fn set_subscribed(&mut self, spec: &str, on: bool) -> Result<String, String> {
2845 let verb = if on {
2846 "subscribed to"
2847 } else {
2848 "unsubscribed from"
2849 };
2850 self.manage(
2851 spec,
2852 move |_account, folder| Manage::Subscribe(folder, on),
2853 |_path| Err("subscription is an IMAP notion".into()),
2854 )
2855 .map(|shown| format!("{verb} {shown}"))
2856 }
2857
2858 pub fn rename_folder(&mut self, spec: &str, new: &str) -> Result<String, String> {
2861 if new.trim().is_empty() {
2862 return Err("no new name given".into());
2863 }
2864 match remote::parse_spec(spec) {
2865 Some((account, from)) => {
2866 let to = new.trim().to_string();
2867 self.on_account(account, Manage::Rename(from.to_string(), to.clone()))?;
2868 Ok(format!("renamed to {to}"))
2869 }
2870 None => {
2871 let from = expand_tilde(spec);
2872 let to = expand_tilde(new);
2873 std::fs::rename(&from, &to).map_err(|e| format!("{e}"))?;
2874 Ok(format!("renamed to {}", to.display()))
2875 }
2876 }
2877 }
2878
2879 fn manage(
2883 &mut self,
2884 spec: &str,
2885 make: impl FnOnce(&str, String) -> Manage,
2886 local: impl FnOnce(std::path::PathBuf) -> Result<String, String>,
2887 ) -> Result<String, String> {
2888 match remote::parse_spec(spec) {
2889 Some((account, folder)) => {
2890 let action = make(account, folder.to_string());
2891 self.on_account(account, action)?;
2892 Ok(format!("imap:{account}/{folder}"))
2893 }
2894 None => local(expand_tilde(spec)),
2895 }
2896 }
2897
2898 fn on_account(&mut self, account: &str, action: Manage) -> Result<(), String> {
2901 self.settle();
2905 self.server_folders = None;
2908 match &mut self.imap {
2909 Some(imap) if imap.facts.account.name == account => imap
2910 .blocking(Job::Manage(action))
2911 .map(drop)
2912 .map_err(|e| format!("{e:#}")),
2913 Some(_) | None => Err("that mailbox is not on the open account".into()),
2914 }
2915 }
2916
2917 pub fn folder_candidates(&mut self) -> Vec<(String, usize)> {
2925 let mut dirs: Vec<(String, usize)> = self
2928 .config
2929 .mail
2930 .mailboxes
2931 .iter()
2932 .filter(|m| m.starts_with("imap:") || expand_tilde(m).join("cur").is_dir())
2933 .map(|m| {
2934 let count = if m.starts_with("imap:") {
2935 0
2936 } else {
2937 maildir::new_count(&expand_tilde(m))
2938 };
2939 (m.clone(), count)
2940 })
2941 .collect();
2942 match &self.imap {
2943 Some(imap) => {
2944 let account = imap.facts.account.name.clone();
2945 match &self.server_folders {
2946 Some(folders) => dirs.extend(
2947 folders
2948 .iter()
2949 .map(|(f, unseen)| (format!("imap:{account}/{f}"), *unseen)),
2950 ),
2951 None => self.refresh_folders(),
2952 }
2953 }
2954 None => dirs.extend(
2955 maildir::discover(&self.dir)
2956 .iter()
2957 .map(|p| (p.display().to_string(), maildir::new_count(p))),
2958 ),
2959 }
2960 let mut seen: HashMap<String, usize> = HashMap::new();
2963 let mut unique: Vec<(String, usize)> = Vec::new();
2964 for (name, count) in dirs {
2965 match seen.get(&name) {
2966 Some(&at) => unique[at].1 = unique[at].1.max(count),
2967 None => {
2968 seen.insert(name.clone(), unique.len());
2969 unique.push((name, count));
2970 }
2971 }
2972 }
2973 let mut dirs = unique;
2974 sort_browser(&mut dirs, self.config.ui.sort_browser.as_deref());
2975 dirs.dedup_by(|a, b| {
2978 if a.0 == b.0 {
2979 b.1 = b.1.max(a.1);
2980 true
2981 } else {
2982 false
2983 }
2984 });
2985 dirs
2986 }
2987
2988 pub fn refresh_folders(&mut self) {
2992 if self.imap.is_none() || self.listing {
2993 return;
2994 }
2995 self.listing = true;
2996 self.send_then(
2997 Job::Folders,
2998 false,
2999 Box::new(|session, done| {
3000 session.listing = false;
3001 match done {
3002 Ok(Done::Folders(folders)) => {
3003 session.server_folders = Some(folders);
3004 session.requests.push(Request::FoldersChanged);
3005 }
3006 Ok(_) => {}
3007 Err(err) => session.error(format!("imap: {err:#}")),
3008 }
3009 }),
3010 );
3011 }
3012
3013 pub fn unseen_count(&self, spec: &str) -> usize {
3022 match remote::parse_spec(spec) {
3023 Some(_) => self.unseen.get(spec).copied().unwrap_or(0),
3024 None => maildir::new_count(&expand_tilde(spec)),
3025 }
3026 }
3027
3028 fn refresh_unseen(&mut self) -> bool {
3031 let Some(imap) = &self.imap else {
3032 return false;
3033 };
3034 let account = imap.facts.account.name.clone();
3035 let wanted: Vec<(String, String)> = self
3036 .config
3037 .mail
3038 .mailboxes
3039 .iter()
3040 .filter_map(|spec| match remote::parse_spec(spec) {
3041 Some((a, folder)) if a == account => Some((spec.clone(), folder.to_string())),
3042 _ => None,
3043 })
3044 .collect();
3045 if wanted.is_empty() {
3046 return false;
3047 }
3048 let (specs, folders): (Vec<String>, Vec<String>) = wanted.into_iter().unzip();
3049 self.start(Job::Unseen(folders), Pending::Counts(specs))
3050 }
3051
3052 pub fn store_edited(&mut self, path: &Path, edited: &[u8]) {
3057 match &self.imap {
3061 Some(imap) => {
3062 let Some(flags) = self
3063 .visible
3064 .get(self.sel)
3065 .map(|&i| self.msgs[i].env.file.flags)
3066 else {
3067 return;
3068 };
3069 let mailbox = imap.facts.mailbox.clone();
3070 let original = path.to_path_buf();
3071 self.send_then(
3072 Job::Append {
3073 mailbox: Some(mailbox),
3074 flags,
3075 body: edited.to_vec(),
3076 },
3077 true,
3078 Box::new(move |session, done| {
3079 if let Err(err) = done {
3080 session.error(format!("cannot store the edited copy: {err:#}"));
3081 return;
3082 }
3083 let rules = session.delete_rules();
3084 if let Some(m) = session
3085 .msgs
3086 .iter_mut()
3087 .find(|m| m.env.file.path == original)
3088 && rules.mark(m)
3089 {
3090 m.dirty = true;
3091 }
3092 session.check_new_mail();
3093 session.note("edited copy appended; original marked deleted ($ purges)");
3094 }),
3095 );
3096 }
3097 None => {
3098 if let Err(err) = std::fs::write(path, edited) {
3099 self.error(format!("cannot write message: {err}"));
3100 return;
3101 }
3102 self.rescan();
3103 self.note("message edited");
3104 }
3105 }
3106 }
3107
3108 pub fn mark_all_read(&mut self) {
3115 if self.deny_readonly() {
3116 return;
3117 }
3118 let targets: Vec<usize> = (0..self.msgs.len())
3119 .filter(|&i| {
3120 let f = &self.msgs[i].env.file;
3121 !f.flags.seen || f.is_new
3122 })
3123 .collect();
3124 if targets.is_empty() {
3125 self.note("no unread messages");
3126 return;
3127 }
3128 self.push_undo("mark all read", &targets);
3129 for &i in &targets {
3130 let m = &mut self.msgs[i];
3131 m.env.file.flags.seen = true;
3132 m.env.file.is_new = false;
3133 m.dirty = true;
3134 }
3135 self.note(format!("{} marked read", targets.len()));
3136 }
3137
3138 pub fn tag_span(&mut self, a: usize, b: usize) {
3142 if self.visible.is_empty() {
3143 return;
3144 }
3145 let last = self.visible.len() - 1;
3146 let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
3147 let (lo, hi) = (lo.min(last), hi.min(last));
3148 let targets: Vec<usize> = self.visible[lo..=hi].to_vec();
3149 self.push_undo("tag", &targets);
3150 for &i in &targets {
3151 self.msgs[i].env.tagged = true;
3152 }
3153 self.note(format!("{} tagged", targets.len()));
3154 }
3155
3156 pub fn mark_old_unread(&mut self) {
3157 if self.read_only || !self.config.mail.mark_old.unwrap_or(true) {
3158 return;
3159 }
3160 for m in &mut self.msgs {
3161 if m.env.file.is_new
3162 && !m.env.file.flags.seen
3163 && !m.env.file.flags.deleted
3164 && let Ok(path) = maildir::store_flags(&m.env.file)
3165 {
3166 m.env.file.path = path;
3167 m.env.file.is_new = false;
3168 }
3169 }
3170 }
3171
3172 pub fn ready_to_leave(&mut self) -> bool {
3176 if self.deleted_count() > 0 {
3177 self.error("deleted messages pending; sync with $ or undelete first");
3178 return false;
3179 }
3180 if self.pending_count() > 0 {
3181 self.sync(false);
3182 if self.pending_count() > 0 {
3183 return false; }
3185 }
3186 true
3187 }
3188
3189 pub fn smtp_account(&self) -> Option<Account> {
3193 if std::env::var("RMUT_SENDMAIL").is_ok() || self.config.mail.sendmail.is_some() {
3194 return None;
3195 }
3196 if let Some(imap) = &self.imap
3197 && imap.facts.account.smtp_host.is_some()
3198 {
3199 return Some(imap.facts.account.clone());
3200 }
3201 self.config
3202 .accounts
3203 .iter()
3204 .find(|a| a.smtp_host.is_some())
3205 .cloned()
3206 }
3207
3208 pub fn copy_message(&mut self, input: &str, delete: bool, tagged: bool, decode: bool) {
3212 if input.is_empty() {
3213 self.error("no mailbox given");
3214 return;
3215 }
3216 let targets = self.op_targets(tagged);
3217 if targets.is_empty() {
3218 return;
3219 }
3220 let paths = self.target_paths(tagged);
3222 if !self.have_bodies(
3223 &paths,
3224 Again::Copy {
3225 input: input.to_string(),
3226 delete,
3227 tagged,
3228 decode,
3229 },
3230 ) {
3231 return;
3232 }
3233 if let Some((account, folder)) = remote::parse_spec(input)
3236 && self
3237 .imap
3238 .as_ref()
3239 .is_some_and(|imap| imap.facts.account.name == account)
3240 {
3241 let target = format!("imap:{account}/{folder}");
3242 let mut errors: Vec<String> = Vec::new();
3243 let mut paths = Vec::new();
3244 let mut messages = Vec::new();
3245 for &i in &targets {
3246 match self.copy_bytes(i, decode) {
3247 Ok(bytes) => {
3248 paths.push(self.msgs[i].env.file.path.clone());
3249 messages.push((self.msgs[i].env.file.flags, bytes));
3250 }
3251 Err(err) => errors.push(err),
3252 }
3253 }
3254 if messages.is_empty() {
3255 let verb = if delete { "save" } else { "copy" };
3256 self.error(format!("cannot {verb}: {}", errors.join("; ")));
3257 return;
3258 }
3259 let job = Job::AppendAll {
3260 mailbox: folder.to_string(),
3261 messages,
3262 };
3263 self.send_then(
3264 job,
3265 true,
3266 Box::new(move |session, done| {
3267 let outcomes = match done {
3268 Ok(Done::Appended(outcomes)) => outcomes,
3269 Ok(_) => Vec::new(),
3270 Err(err) => vec![Err(format!("{err:#}"))],
3271 };
3272 let tried = outcomes.len();
3273 let mut copied = Vec::new();
3274 for (path, outcome) in paths.iter().zip(outcomes) {
3275 match outcome {
3276 Ok(()) => copied
3279 .extend(session.msgs.iter().position(|m| m.env.file.path == *path)),
3280 Err(err) => errors.push(err),
3281 }
3282 }
3283 if paths.len() > tried.max(1) {
3284 errors.push(format!("{} not tried", paths.len() - tried.max(1)));
3285 }
3286 let note = Some(format!("the copy in {target} stays"));
3287 session.finish_copy(Copied {
3288 copied,
3289 errors,
3290 created: Vec::new(),
3291 target,
3292 note,
3293 delete,
3294 tagged,
3295 });
3296 }),
3297 );
3298 return;
3299 }
3300 let mut created: Vec<PathBuf> = Vec::new();
3303 let mut copied = Vec::new();
3304 let mut errors: Vec<String> = Vec::new();
3305 let mut target = String::new();
3306 for &i in &targets {
3307 match self.copy_one(i, input, decode, &mut created) {
3308 Ok(shown) => {
3309 target = shown;
3310 copied.push(i);
3311 }
3312 Err(err) => errors.push(err),
3315 }
3316 }
3317 self.finish_copy(Copied {
3318 copied,
3319 errors,
3320 created,
3321 target,
3322 note: None,
3323 delete,
3324 tagged,
3325 });
3326 }
3327
3328 fn finish_copy(&mut self, done: Copied) {
3331 let Copied {
3332 copied,
3333 errors,
3334 created,
3335 target,
3336 note,
3337 delete,
3338 tagged,
3339 } = done;
3340 let verb = if delete { "save" } else { "copy" };
3341 if copied.is_empty() {
3342 self.error(format!("cannot {verb}: {}", errors.join("; ")));
3343 return;
3344 }
3345 let marks = copied.iter().map(|&i| self.mark(i)).collect();
3346 self.push_undo_step(UndoStep {
3347 what: format!("{verb} to {target}"),
3348 marks,
3349 sel: self.selected_path(),
3350 created,
3351 note,
3352 rewritten: Vec::new(),
3353 });
3354 let n = copied.len();
3355 if delete {
3356 let rules = self.delete_rules();
3357 for &i in &copied {
3358 if rules.mark(&mut self.msgs[i]) {
3359 self.msgs[i].dirty = true;
3360 }
3361 }
3362 }
3363 if delete
3367 && !tagged
3368 && errors.is_empty()
3369 && let Some(pos) = self.step_message(true, true)
3370 {
3371 self.sel = pos;
3372 }
3373 let mut status = match (delete, n) {
3374 (true, 1) => format!("saved to {target} (original marked deleted)"),
3375 (true, _) => format!("saved {n} to {target} (originals marked deleted)"),
3376 (false, 1) => format!("copied to {target}"),
3377 (false, _) => format!("copied {n} to {target}"),
3378 };
3379 if !errors.is_empty() {
3380 status += &format!("; {} failed: {}", errors.len(), errors.join("; "));
3381 self.error(status);
3382 } else {
3383 self.note(status);
3384 }
3385 }
3386
3387 fn copy_bytes(&mut self, i: usize, decode: bool) -> Result<Vec<u8>, String> {
3392 if decode {
3393 Ok(self.displayed_text(i)?.into_bytes())
3394 } else {
3395 self.message_bytes(i)
3396 .ok_or_else(|| "cannot read the message".into())
3397 }
3398 }
3399
3400 fn copy_one(
3405 &mut self,
3406 i: usize,
3407 spec: &str,
3408 decode: bool,
3409 created: &mut Vec<PathBuf>,
3410 ) -> Result<String, String> {
3411 if remote::parse_spec(spec).is_some() {
3412 return Err("can only save to a folder of the open account".into());
3413 }
3414 let flags = self.msgs[i].env.file.flags;
3415 let bytes = self.copy_bytes(i, decode)?;
3416 let dir = expand_tilde(spec);
3417 maildir::create(&dir)
3418 .and_then(|()| maildir::deliver(&dir, &bytes, flags))
3419 .map(|path| {
3420 created.push(path);
3421 dir.display().to_string()
3422 })
3423 .map_err(|err| format!("{err:#}"))
3424 }
3425
3426 pub fn create_alias(&mut self, nick: &str, addr: &str) {
3428 if nick.is_empty() || nick.contains(char::is_whitespace) {
3429 self.error("the alias nick must be one word");
3430 return;
3431 }
3432 match alias::append_to(self.config.mail.alias_file.as_deref(), nick, addr) {
3433 Ok(_) => self.note(format!("added: alias {nick} {addr}")),
3434 Err(err) => self.error(format!("cannot save the alias: {err:#}")),
3435 }
3436 }
3437
3438 pub fn pipe_message(&mut self, command: &str, tagged: bool) {
3442 if command.is_empty() {
3443 self.error("no command given");
3444 return;
3445 }
3446 let paths = self.target_paths(tagged);
3447 if !self.have_bodies(
3448 &paths,
3449 Again::Pipe {
3450 command: command.to_string(),
3451 tagged,
3452 },
3453 ) {
3454 return;
3455 }
3456 let targets = self.op_targets(tagged);
3457 let n = targets.len();
3458 let decode = self.config.mail.pipe_decode.unwrap_or(false);
3459 let split = self.config.mail.pipe_split.unwrap_or(false);
3460 let sep = self
3461 .config
3462 .mail
3463 .pipe_sep
3464 .clone()
3465 .unwrap_or_else(|| "\n".into());
3466 match self.run_over(&targets, command, decode, split, &sep) {
3467 Ok(()) => self.note(match n {
3468 1 => format!("piped to {command}"),
3469 _ => format!("piped {n} messages to {command}"),
3470 }),
3471 Err(err) => self.error(format!("pipe failed: {err}")),
3472 }
3473 }
3474
3475 pub fn bounce_current(&mut self, to: &str, tagged: bool) {
3478 let rcpts = compose::addresses(to);
3479 if rcpts.is_empty() {
3480 self.error(format!("cannot parse the addresses in {to:?}"));
3481 return;
3482 }
3483 let paths = self.target_paths(tagged);
3484 if !self.have_bodies(
3485 &paths,
3486 Again::Bounce {
3487 to: to.to_string(),
3488 tagged,
3489 },
3490 ) {
3491 return;
3492 }
3493 let targets = self.op_targets(tagged);
3494 let mut sent = 0usize;
3495 for i in targets {
3496 let Some(bytes) = self.message_bytes(i) else {
3497 return;
3498 };
3499 if let Err(err) = self.bounce_one(&bytes, to, &rcpts) {
3500 self.error(format!("bounce failed: {err:#}"));
3501 return;
3502 }
3503 sent += 1;
3504 }
3505 self.note(match sent {
3506 1 => format!("message bounced to {to}"),
3507 _ => format!("{sent} messages bounced to {to}"),
3508 });
3509 }
3510
3511 fn bounce_one(&mut self, bytes: &[u8], to: &str, rcpts: &[String]) -> Result<()> {
3514 let host = maildir::hostname();
3515 let from = self
3516 .current_identity(rcpts)
3517 .from_line()
3518 .unwrap_or_else(|| default_from(&host));
3519 let text = compose::bounce_text(
3520 bytes,
3521 &from,
3522 to,
3523 &compose::rfc2822_now(),
3524 &compose::make_message_id(&host),
3525 );
3526 let envelope_from = compose::bare_address(&from).unwrap_or_else(|| from.clone());
3527 let envelope = self.config.mail.envelope(&envelope_from);
3528 match self.smtp_account() {
3529 Some(account) => account_password(&account).and_then(|password| {
3530 smtp::send(
3531 &account,
3532 &password,
3533 &envelope_from,
3534 rcpts,
3535 text.as_bytes(),
3536 &envelope,
3537 )
3538 }),
3539 None => run_sendmail(
3540 text.as_bytes(),
3541 self.config.mail.sendmail.as_deref(),
3542 Some(rcpts),
3543 &envelope,
3544 ),
3545 }
3546 }
3547
3548 pub fn open_message(&mut self) {
3551 let Some(path) = self.selected_path() else {
3552 return;
3553 };
3554 if !self.have_bodies(std::slice::from_ref(&path), Again::View) {
3555 return;
3556 }
3557 self.mark_read();
3558 match self.load_view(&path) {
3559 Ok(view) => self.requests.push(Request::ShowMessage(Box::new(view))),
3560 Err(err) => self.error(format!("cannot open message: {err:#}")),
3561 }
3562 }
3563
3564 pub fn load_view(&mut self, path: &Path) -> Result<message::MessageView> {
3567 self.settle();
3571 if remote::is_partial(path)
3573 && let Some(imap) = &mut self.imap
3574 {
3575 imap.blocking(Job::FetchBodies(vec![path.to_path_buf()]))
3576 .context("cannot fetch message")?;
3577 if let Some(m) = self.msgs.iter_mut().find(|m| m.env.file.path == path)
3579 && let Ok(raw) = std::fs::read(path)
3580 {
3581 m.env.lines = Some(message::body_lines(&raw));
3582 }
3583 }
3584 let mut view = message::load_with(path, &self.display)?;
3585 if let Ok(raw) = std::fs::read(path)
3588 && let Some(p) = pgp::view(&self.config.pgp, &raw)
3589 {
3590 match p.body {
3591 Some(pgp::Body::Entity(raw)) => {
3595 view.body = message::render_entity(&raw, &self.display);
3596 }
3597 Some(pgp::Body::Text(text)) => view.body = text,
3598 None => {}
3599 }
3600 view.body = format!("{}\n\n{}", p.note, view.body);
3601 }
3602 Ok(view)
3603 }
3604
3605 fn displayed_text(&mut self, i: usize) -> Result<String, String> {
3609 let path = self.msgs[i].env.file.path.clone();
3610 let view = self
3611 .load_view(&path)
3612 .map_err(|err| format!("cannot decode: {err:#}"))?;
3613 let mut text = String::new();
3614 for (name, value) in &view.brief {
3615 text += &format!("{name}: {value}\n");
3616 }
3617 text.push('\n');
3618 text += &view.body;
3619 if !text.ends_with('\n') {
3620 text.push('\n');
3621 }
3622 Ok(text)
3623 }
3624
3625 pub fn print_current(&mut self, tagged: bool) {
3629 let paths = self.target_paths(tagged);
3630 if !self.have_bodies(&paths, Again::Print { tagged }) {
3631 return;
3632 }
3633 let targets = self.op_targets(tagged);
3634 if targets.is_empty() {
3635 return;
3636 }
3637 let command = self
3638 .config
3639 .mail
3640 .print
3641 .clone()
3642 .unwrap_or_else(|| "lpr".into());
3643 let decode = self.config.mail.print_decode.unwrap_or(true);
3644 let split = self.config.mail.print_split.unwrap_or(false);
3645 let n = targets.len();
3646 match self.run_over(&targets, &command, decode, split, "\n") {
3647 Ok(()) => self.note(match n {
3648 1 => format!("printed via {command}"),
3649 _ => format!("printed {n} messages via {command}"),
3650 }),
3651 Err(err) => self.error(format!("print failed: {err}")),
3652 }
3653 }
3654
3655 fn op_one_bytes(&mut self, i: usize, decode: bool) -> Result<Vec<u8>, String> {
3658 if decode {
3659 self.displayed_text(i).map(String::into_bytes)
3660 } else {
3661 self.message_bytes(i)
3662 .map(|mut b| {
3663 if !b.ends_with(b"\n") {
3664 b.push(b'\n');
3665 }
3666 b
3667 })
3668 .ok_or_else(|| "cannot read the message".into())
3669 }
3670 }
3671
3672 fn run_over(
3676 &mut self,
3677 targets: &[usize],
3678 command: &str,
3679 decode: bool,
3680 split: bool,
3681 sep: &str,
3682 ) -> Result<(), String> {
3683 if split {
3684 for &i in targets {
3685 let bytes = self.op_one_bytes(i, decode)?;
3686 pipe_to(command, &bytes).map_err(|err| format!("{err:#}"))?;
3687 }
3688 return Ok(());
3689 }
3690 let mut all: Vec<u8> = Vec::new();
3691 for (n, &i) in targets.iter().enumerate() {
3692 if n > 0 {
3693 all.extend_from_slice(sep.as_bytes());
3694 }
3695 all.extend_from_slice(&self.op_one_bytes(i, decode)?);
3696 }
3697 pipe_to(command, &all).map_err(|err| format!("{err:#}"))
3698 }
3699
3700 pub fn current_identity(&self, rcpts: &[String]) -> rmut_core::config::Identity {
3703 self.config.identity_for(
3704 &self.title,
3705 rcpts,
3706 self.imap.as_ref().map(|imap| &imap.facts.account),
3707 )
3708 }
3709
3710 pub fn matching_message_hooks(&self) -> Vec<usize> {
3712 let Some(env) = self.visible.get(self.sel).map(|&mi| &self.msgs[mi].env) else {
3713 return Vec::new();
3714 };
3715 let pos = pattern::Position {
3718 number: self.sel + 1,
3719 current: self.sel + 1,
3720 last: self.visible.len(),
3721 duplicate: false,
3722 };
3723 self.message_hooks
3724 .iter()
3725 .enumerate()
3726 .filter(|(_, h)| pattern::matches_in(&h.patterns, env, self.scope(pos), None))
3727 .map(|(i, _)| i)
3728 .collect()
3729 }
3730
3731 pub fn reply_hook_lines(&self, path: &Path) -> Vec<String> {
3735 if self.reply_hooks.is_empty() {
3736 return Vec::new();
3737 }
3738 let Some(env) = self.msgs.iter().find(|m| m.env.file.path == *path) else {
3739 return Vec::new();
3740 };
3741 let scope = self.scope(pattern::Position::default());
3742 self.reply_hooks
3743 .iter()
3744 .filter(|h| pattern::matches_in(&h.patterns, &env.env, scope, None))
3745 .map(|h| h.value.clone())
3746 .collect()
3747 }
3748
3749 pub fn fcc_hook_target(&self, draft: &str, path: &Path) -> Option<String> {
3752 if self.fcc_hooks.is_empty() {
3753 return None;
3754 }
3755 let env = compose::draft_envelope(draft, path);
3756 let scope = self.scope(pattern::Position::default());
3757 self.fcc_hooks
3758 .iter()
3759 .find(|h| pattern::matches_in(&h.patterns, &env, scope, None))
3760 .map(|h| h.value.clone())
3761 }
3762
3763 pub fn crypt_key_for(&self, address: &str) -> Option<String> {
3766 self.crypt_hooks
3767 .iter()
3768 .find(|(m, _)| m.is_match(address))
3769 .map(|(_, key)| key.clone())
3770 }
3771
3772 pub fn notmuch_mirror(&mut self, query: &str) -> Option<(PathBuf, usize)> {
3780 if query.is_empty() {
3781 return None;
3782 }
3783 let out = Command::new("notmuch")
3784 .args(["search", "--output=files", "--limit=1000", "--", query])
3785 .output();
3786 let out = match out {
3787 Ok(out) if out.status.success() => out,
3788 Ok(out) => {
3789 let err = String::from_utf8_lossy(&out.stderr);
3790 self.error(format!("notmuch: {}", err.trim()));
3791 return None;
3792 }
3793 Err(err) => {
3794 self.error(format!("notmuch: {err}"));
3795 return None;
3796 }
3797 };
3798 let stdout = String::from_utf8_lossy(&out.stdout);
3799 let files: Vec<&str> = stdout.lines().filter(|l| !l.trim().is_empty()).collect();
3800 if files.is_empty() {
3801 self.error("notmuch: no matches");
3802 return None;
3803 }
3804 if !self.ready_to_leave() {
3805 return None;
3806 }
3807 let dir = remote::cache_base().join("notmuch");
3808 let build = || -> Result<()> {
3809 for sub in ["cur", "new", "tmp"] {
3810 std::fs::create_dir_all(dir.join(sub))?;
3811 }
3812 for entry in std::fs::read_dir(dir.join("cur"))?.flatten() {
3813 let _ = std::fs::remove_file(entry.path());
3814 }
3815 for (i, file) in files.iter().enumerate() {
3816 let base = Path::new(file)
3817 .file_name()
3818 .map(|n| n.to_string_lossy().into_owned())
3819 .unwrap_or_else(|| format!("{i}"));
3820 let _ = std::os::unix::fs::symlink(
3823 file,
3824 dir.join("cur").join(format!("{i:04}.{base}")),
3825 );
3826 }
3827 Ok(())
3828 };
3829 if let Err(err) = build() {
3830 self.note(format!("notmuch mirror: {err:#}"));
3831 return None;
3832 }
3833 Some((dir, files.len()))
3834 }
3835
3836 pub fn compose_base(&self) -> Option<ComposeBase> {
3837 let &mi = self.visible.get(self.sel)?;
3838 let env = &self.msgs[mi].env;
3839 let view = message::load(&env.file.path).ok()?;
3840 let get = |name: &str| {
3841 view.all
3842 .iter()
3843 .filter(|(k, _)| k.eq_ignore_ascii_case(name))
3844 .map(|(_, v)| v.clone())
3845 .collect::<Vec<_>>()
3846 .join(", ")
3847 };
3848 let from_hdr = get("From");
3849 let from_me = !self.config.mail.reply_self && self.me().wrote(&from_hdr);
3852 let (reply_to, has_reply_to) = {
3853 let rt = get("Reply-To");
3854 if from_me && !get("To").trim().is_empty() {
3855 (get("To"), false)
3856 } else if rt.trim().is_empty() {
3857 (from_hdr.clone(), false)
3858 } else {
3859 let differs = rt.trim() != from_hdr.trim();
3860 (rt, differs)
3861 }
3862 };
3863 Some(ComposeBase {
3864 path: env.file.path.clone(),
3865 reply_to,
3866 from_hdr,
3867 has_reply_to,
3868 orig_to: get("To"),
3869 orig_cc: get("Cc"),
3870 list_post: compose::list_post_address(&get("List-Post")),
3871 followup_to: get("Mail-Followup-To"),
3872 from_addr: compose::addresses(&get("From"))
3873 .into_iter()
3874 .next()
3875 .unwrap_or_default(),
3876 from_display: env.from.clone(),
3877 subject: env.subject.clone(),
3878 date: env.date,
3879 msg_id: env.msg_id.clone(),
3880 references: env.references.clone(),
3881 })
3882 }
3883
3884 pub fn list_target(&self, base: &ComposeBase) -> Option<String> {
3890 if let Some(addr) = &base.list_post {
3891 return Some(addr.clone());
3892 }
3893 let mut candidates = compose::addresses(&base.orig_to);
3894 candidates.extend(compose::addresses(&base.orig_cc));
3895 candidates
3896 .into_iter()
3897 .find(|a| self.lists.iter().any(|m| m.is_match(a)))
3898 }
3899
3900 pub fn followup_header(&self, to: &str, cc: Option<&str>, from: &str) -> Option<String> {
3904 if self.lists.is_empty() {
3905 return None;
3906 }
3907 let mut rcpts = compose::addresses(to);
3908 rcpts.extend(compose::addresses(cc.unwrap_or_default()));
3909 if !rcpts
3910 .iter()
3911 .any(|a| self.lists.iter().any(|m| m.is_match(a)))
3912 {
3913 return None;
3914 }
3915 let subscribed = rcpts
3916 .iter()
3917 .any(|a| self.subscribed.iter().any(|m| m.is_match(a)));
3918 let value = compose::followup_to(to, cc.unwrap_or_default(), self.me(), subscribed, from);
3919 (!value.is_empty()).then_some(value)
3920 }
3921
3922 pub fn edit_headers(&self) -> bool {
3925 self.config.mail.edit_headers.unwrap_or(false)
3926 }
3927
3928 pub fn stage_draft(&self, text: &str) -> Result<(PathBuf, Option<String>)> {
3932 let text = compose::apply_my_hdr(text, &self.config.mail.my_hdr);
3936 if self.edit_headers() {
3937 return Ok((write_draft(&text)?, None));
3938 }
3939 let (head, body) = text.split_once("\n\n").unwrap_or((text.trim_end(), ""));
3940 Ok((write_draft(body)?, Some(head.to_string())))
3941 }
3942
3943 pub fn compose_from(&self, base: Option<&ComposeBase>, to: &str) -> Option<String> {
3947 let realname = self.config.identity.reverse_realname.unwrap_or(true);
3951 if self.config.identity.reverse_name
3952 && let Some(b) = base
3953 && let Some(from) = compose::reverse_from(&b.orig_to, &b.orig_cc, self.me(), realname)
3954 {
3955 let rcpts = compose::addresses(to);
3956 return Some(match self.current_identity(&rcpts).name {
3957 Some(name) if !realname && !from.contains('<') => format!("{name} <{from}>"),
3958 _ => from,
3959 });
3960 }
3961 let rcpts = compose::addresses(to);
3962 self.current_identity(&rcpts).from_line()
3963 }
3964
3965 pub fn forward_attaches(&self) -> bool {
3966 self.config.mail.forward.as_deref() == Some("attach")
3967 }
3968
3969 pub fn default_fcc(&self, draft: Option<&Compose>) -> String {
3977 if let Some(compose) = draft
3978 && let Ok(full) = draft_full(compose)
3979 && let Some(mailbox) = self.fcc_hook_target(&full, &compose.path)
3980 {
3981 return mailbox;
3982 }
3983 match &self.imap {
3984 Some(imap) => format!(
3985 "imap:{}/{}",
3986 imap.facts.account.name, imap.facts.account.sent_folder
3987 ),
3988 None => self.config.mail.sent.clone().unwrap_or_default(),
3989 }
3990 }
3991
3992 pub fn default_security(&self) -> Security {
3994 Self::combine(
3995 self.config.pgp.sign_by_default,
3996 self.config.pgp.encrypt_by_default,
3997 )
3998 }
3999
4000 fn combine(sign: bool, encrypt: bool) -> Security {
4001 match (sign, encrypt) {
4002 (true, true) => Security::Both,
4003 (true, false) => Security::Sign,
4004 (false, true) => Security::Encrypt,
4005 (false, false) => Security::None,
4006 }
4007 }
4008
4009 pub fn security_for(&self, kind: &ComposeKind, base: Option<&ComposeBase>) -> Security {
4016 let mut sign = self.config.pgp.sign_by_default;
4017 let mut encrypt = self.config.pgp.encrypt_by_default;
4018 let is_reply = matches!(
4019 kind,
4020 ComposeKind::Reply | ComposeKind::GroupReply | ComposeKind::ListReply
4021 );
4022 if is_reply
4023 && let Some(base) = base
4024 && let Ok(raw) = std::fs::read(&base.path)
4025 {
4026 let crypto = rmut_core::pgp::classify(&raw);
4027 if crypto.encrypted && self.config.pgp.reply_encrypt {
4028 encrypt = true;
4029 }
4030 if crypto.signed && self.config.pgp.reply_sign {
4031 sign = true;
4032 }
4033 if crypto.encrypted && self.config.pgp.reply_sign_encrypted {
4039 sign = true;
4040 }
4041 }
4042 Self::combine(sign, encrypt)
4043 }
4044
4045 pub fn tick_outbox(&mut self) {
4048 if self.holding() {
4051 return;
4052 }
4053 while self.outbox.first().is_some_and(|h| h.due <= Instant::now()) {
4054 let held = self.outbox.remove(0);
4055 self.deliver(held);
4056 }
4057 if let Some(next) = self.outbox.first()
4058 && !self.notice().is_some_and(|n| n.is_error())
4059 {
4060 let left = next.due.saturating_duration_since(Instant::now()).as_secs() + 1;
4061 self.note(format!("sending {} in {left}s (z cancels)", next.label));
4062 }
4063 }
4064
4065 pub fn flush_outbox(&mut self) -> Vec<String> {
4072 let mut trouble = Vec::new();
4073 while !self.outbox.is_empty() {
4074 let held = self.outbox.remove(0);
4075 let label = held.label.clone();
4076 self.deliver(held);
4077 if let Some(err) = self.notice().filter(|n| n.is_error()).map(|n| n.text()) {
4078 trouble.push(format!("{label}: {err}"));
4079 self.clear_notice();
4080 }
4081 }
4082 trouble
4083 }
4084
4085 pub fn hold_send(&mut self, held: Held) {
4089 self.outbox.push(held);
4090 }
4091
4092 pub fn cancel_send(&mut self) -> bool {
4095 let Some(held) = self.outbox.pop() else {
4096 return false;
4097 };
4098 self.note(format!("send cancelled: {}", held.label));
4099 self.hand_back(held.state);
4100 true
4101 }
4102
4103 pub fn deliver(&mut self, held: Held) {
4108 self.settle();
4112 let Held {
4113 state: compose_state,
4114 text: final_text,
4115 fcc_text,
4116 fcc: chosen,
4117 ..
4118 } = held;
4119 let envelope = self
4120 .config
4121 .mail
4122 .envelope(&compose::from_address(&final_text).unwrap_or_default());
4123 let send_result = match self.smtp_account() {
4124 Some(account) => send_via_smtp(&account, &final_text, &envelope),
4125 None => run_sendmail(
4126 final_text.as_bytes(),
4127 self.config.mail.sendmail.as_deref(),
4128 None,
4129 &envelope,
4130 ),
4131 };
4132 let copy = fcc_text.unwrap_or(final_text);
4135 match send_result {
4136 Ok(()) => {
4137 let mut note = String::from("message sent");
4138 let mut fcc_to_server = None;
4139 let skip_copy = chosen.as_deref() == Some("")
4140 || (chosen.is_none() && self.config.mail.copy == Some(false));
4141 if skip_copy {
4142 } else if let Some(fcc) = chosen
4144 .as_deref()
4145 .filter(|f| Some(*f) != Some(self.default_fcc(None).as_str()))
4146 {
4147 let dir = expand_tilde(fcc);
4149 let flags = maildir::Flags {
4150 seen: true,
4151 ..Default::default()
4152 };
4153 if dir.join("cur").is_dir()
4154 && maildir::deliver(&dir, copy.as_bytes(), flags).is_ok()
4155 {
4156 note += &format!(", copy in {fcc}");
4157 } else {
4158 note += &format!(", Fcc to {fcc} failed");
4159 }
4160 } else {
4161 match &mut self.imap {
4162 Some(_) => fcc_to_server = Some(copy.into_bytes()),
4167 None => {
4168 let sent_dir = self
4169 .config
4170 .mail
4171 .sent
4172 .as_deref()
4173 .map(expand_tilde)
4174 .filter(|p| p.join("cur").is_dir())
4175 .or_else(|| {
4176 maildir::find_special(&self.dir, &["sent", "sent-mail"])
4177 });
4178 match sent_dir {
4179 Some(sent) => {
4180 let flags = maildir::Flags {
4181 seen: true,
4182 ..Default::default()
4183 };
4184 match maildir::deliver(&sent, copy.as_bytes(), flags) {
4185 Ok(_) => note += ", copy in Sent",
4186 Err(_) => note += ", Fcc to Sent failed",
4187 }
4188 }
4189 None => note += " (no Sent maildir, no copy kept)",
4190 }
4191 }
4192 }
4193 }
4194 if let Ok(full) = draft_full(&compose_state) {
4196 for a in compose::extract_attachments(&full).1 {
4197 if a.unlink {
4198 let _ = std::fs::remove_file(&a.path);
4199 }
4200 }
4201 }
4202 let _ = std::fs::remove_file(&compose_state.path);
4203 if let Some(src) = &compose_state.recall_source {
4204 let _ = std::fs::remove_file(src);
4205 }
4206 match fcc_to_server {
4207 Some(body) => self.fcc_to_sent(note, body),
4208 None => self.note(note),
4209 }
4210 }
4211 Err(err) => {
4212 self.error(format!("send failed: {err:#}"));
4213 self.hand_back(compose_state);
4214 }
4215 }
4216 }
4217
4218 fn fcc_to_sent(&mut self, sent: String, body: Vec<u8>) {
4222 self.send_then(
4223 Job::Append {
4224 mailbox: None,
4225 flags: maildir::Flags {
4226 seen: true,
4227 ..Default::default()
4228 },
4229 body,
4230 },
4231 false,
4232 Box::new(move |session, done| match done {
4233 Ok(Done::Folder(folder)) => session.note(format!("{sent}, copy in {folder}")),
4234 Ok(_) => session.note(format!("{sent}, copy in Sent")),
4235 Err(err) => session.error(format!("{sent}, Fcc to Sent failed: {err:#}")),
4236 }),
4237 );
4238 }
4239
4240 pub fn attachment_forgotten(&self, raw: &str, compose: &Compose) -> bool {
4245 if self.config.mail.abort_noattach.as_deref().unwrap_or("no") == "no" {
4246 return false;
4247 }
4248 if compose.attach.is_some() || !compose::extract_attachments(raw).1.is_empty() {
4249 return false;
4250 }
4251 let body = raw.split_once("\n\n").map_or("", |(_, body)| body);
4252 for line in body.lines() {
4253 if line.trim_end() == "--" || line == "-- " {
4254 break;
4255 }
4256 if self.quote_re.is_match(line) {
4257 continue;
4258 }
4259 if self.attach_re.is_match(line) {
4260 return true;
4261 }
4262 }
4263 false
4264 }
4265
4266 pub fn secure_message(
4270 &self,
4271 security: Security,
4272 text: String,
4273 files: &[compose::Attachment],
4274 original: Option<&[u8]>,
4275 ) -> Result<String> {
4276 let cfg = &self.config.pgp;
4277 let recipients = |text: &str| -> Result<Vec<String>> {
4280 let (mut rcpts, _) = compose::smtp_envelope(text)?;
4281 if let Some(from) = compose::from_address(text) {
4282 rcpts.push(from);
4283 }
4284 for r in &mut rcpts {
4287 if let Some(key) = self.crypt_key_for(r) {
4288 *r = key;
4289 }
4290 }
4291 rcpts.sort();
4292 rcpts.dedup();
4293 Ok(rcpts)
4294 };
4295 let flowed = self.config.mail.text_flowed;
4296 if files.is_empty() && original.is_none() {
4297 return match security {
4298 Security::None if flowed => Ok(compose::flow_plain(&text)),
4301 Security::None => Ok(text),
4302 Security::Sign => pgp::sign_message(cfg, &text, flowed),
4303 Security::Encrypt | Security::Both => pgp::encrypt_message(
4304 cfg,
4305 &recipients(&text)?,
4306 security == Security::Both,
4307 &text,
4308 flowed,
4309 ),
4310 };
4311 }
4312 let (head, body) = text.split_once("\n\n").unwrap_or((text.trim_end(), ""));
4313 let entity = compose::mixed_entity(body, files, original, flowed)?;
4314 match security {
4315 Security::None => Ok(format!("{}\nMIME-Version: 1.0\n{entity}", head.trim_end())),
4316 Security::Sign => pgp::sign_entity(cfg, head, entity.as_bytes()),
4317 Security::Encrypt | Security::Both => pgp::encrypt_entity(
4318 cfg,
4319 &recipients(&text)?,
4320 security == Security::Both,
4321 head,
4322 entity.as_bytes(),
4323 ),
4324 }
4325 }
4326
4327 pub fn postponed_dir(&self) -> Option<PathBuf> {
4328 self.config
4329 .mail
4330 .postponed
4331 .as_deref()
4332 .map(expand_tilde)
4333 .filter(|p| p.join("cur").is_dir())
4334 .or_else(|| {
4335 maildir::find_special(&self.dir, &["drafts", "postponed", "rmut-postponed"])
4336 })
4337 }
4338
4339 pub fn has_postponed(&self) -> bool {
4340 self.postponed_dir()
4341 .and_then(|d| maildir::scan(&d).ok())
4342 .is_some_and(|files| !files.is_empty())
4343 }
4344
4345 pub fn postpone_draft(&mut self, compose_state: Compose) {
4348 let target = match self.postponed_dir() {
4349 Some(d) => Ok(d),
4350 None => {
4351 let d = self.dir.join(".rmut-postponed");
4352 maildir::create(&d).map(|()| d)
4353 }
4354 };
4355 let result = target.and_then(|dir| {
4356 let bytes = draft_full(&compose_state)?.into_bytes();
4359 let flags = maildir::Flags {
4360 draft: true,
4361 seen: true,
4362 ..Default::default()
4363 };
4364 maildir::deliver(&dir, &bytes, flags)
4365 });
4366 match result {
4367 Ok(path) => {
4368 let _ = std::fs::remove_file(&compose_state.path);
4369 self.note(format!("postponed to {}", path.display()));
4370 }
4371 Err(err) => {
4372 self.error(format!(
4373 "postpone failed: {err:#}; draft at {}",
4374 compose_state.path.display()
4375 ));
4376 }
4377 }
4378 }
4379
4380 pub fn recall_file(&mut self, source: PathBuf) -> Option<Compose> {
4383 let result = std::fs::read_to_string(&source)
4384 .map_err(anyhow::Error::from)
4385 .and_then(|content| self.stage_draft(&content));
4386 match result {
4387 Ok((path, hidden_head)) => Some(Compose {
4388 path,
4389 recall_source: Some(source),
4390 security: self.default_security(),
4391 attach: None,
4392 hidden_head,
4393 fcc: None,
4394 }),
4395 Err(err) => {
4396 self.error(format!("cannot recall: {err:#}"));
4397 None
4398 }
4399 }
4400 }
4401
4402 pub fn draft_head(&self) -> String {
4404 let Some(c) = &self.draft else {
4405 return String::new();
4406 };
4407 match &c.hidden_head {
4408 Some(head) => head.clone(),
4409 None => {
4410 let text = std::fs::read_to_string(&c.path).unwrap_or_default();
4411 match text.split_once("\n\n") {
4412 Some((head, _)) => head.to_string(),
4413 None => text.trim_end().to_string(),
4414 }
4415 }
4416 }
4417 }
4418
4419 pub fn edit_draft_head(&mut self, f: impl Fn(&str) -> String) {
4421 let Some(c) = &mut self.draft else {
4422 return;
4423 };
4424 match &mut c.hidden_head {
4425 Some(head) => *head = f(head),
4426 None => {
4427 if let Ok(text) = std::fs::read_to_string(&c.path) {
4428 let (head, body) = text.split_once("\n\n").unwrap_or((text.trim_end(), ""));
4429 let _ = std::fs::write(&c.path, format!("{}\n\n{body}", f(head)));
4430 }
4431 }
4432 }
4433 }
4434
4435 pub fn set_draft_header(&mut self, name: &str, value: &str) {
4437 let value = value.trim().to_string();
4438 let name = name.to_string();
4439 self.edit_draft_head(|head| {
4440 let mut lines: Vec<&str> = head
4441 .lines()
4442 .filter(|l| {
4443 !l.split_once(':')
4444 .is_some_and(|(k, _)| k.trim().eq_ignore_ascii_case(&name))
4445 })
4446 .collect();
4447 let added = format!("{name}: {value}");
4448 if !value.is_empty() {
4449 lines.push(&added);
4450 }
4451 lines.join("\n")
4452 });
4453 }
4454
4455 pub fn draft_header(&self, name: &str) -> String {
4456 header_value(&self.draft_head(), name).unwrap_or_default()
4457 }
4458
4459 pub fn send_draft(&mut self) -> Option<Ask> {
4463 let compose_state = self.draft.take()?;
4464 let raw = match draft_full(&compose_state) {
4465 Ok(r) => r,
4466 Err(err) => {
4467 self.error(format!("cannot read draft: {err}"));
4468 return None;
4469 }
4470 };
4471 if !mem::take(&mut self.attach_confirmed) && self.attachment_forgotten(&raw, &compose_state)
4474 {
4475 self.draft = Some(compose_state);
4476 match self.config.mail.abort_noattach.as_deref() {
4477 Some("yes") => {
4480 self.error("no attachment: not sent (abort_noattach); a attaches one");
4481 self.requests.push(Request::ShowDraft);
4482 return None;
4483 }
4484 _ => {
4485 return Some(Ask::Key {
4486 label:
4487 "The body mentions an attachment and none is attached. Send? (y/n): "
4488 .into(),
4489 what: AskKind::NoAttach,
4490 });
4491 }
4492 }
4493 }
4494 let keep_attachments = match self.fcc_attach_answer.take() {
4497 Some(answer) => answer,
4498 None => match self.config.mail.fcc_attach.as_deref().unwrap_or("yes") {
4499 "no" => false,
4500 ask @ ("ask-yes" | "ask-no")
4501 if compose_state.attach.is_some()
4502 || !compose::extract_attachments(&raw).1.is_empty() =>
4503 {
4504 self.draft = Some(compose_state);
4505 return Some(Ask::Key {
4506 label: "Save attachments in Fcc? (y/n): ".into(),
4507 what: AskKind::FccAttach {
4508 default_yes: ask == "ask-yes",
4509 },
4510 });
4511 }
4512 _ => true,
4513 },
4514 };
4515 let draft_path = self
4518 .draft
4519 .as_ref()
4520 .map(|c| c.path.clone())
4521 .unwrap_or_default();
4522 let hook_fcc = self.fcc_hook_target(&raw, &draft_path);
4523 let (raw, files) = compose::extract_attachments(&raw);
4524 let host = maildir::hostname();
4525 let from = self
4526 .current_identity(&[])
4527 .from_line()
4528 .unwrap_or_else(|| default_from(&host));
4529 let msg_host = self
4531 .config
4532 .mail
4533 .hostname
4534 .clone()
4535 .filter(|h| !h.trim().is_empty())
4536 .unwrap_or(host);
4537 let final_text = match compose::finalize_with(
4538 &raw,
4539 &from,
4540 &compose::make_message_id(&msg_host),
4541 &compose::rfc2822_now(),
4542 self.config.mail.user_agent.unwrap_or(false),
4543 ) {
4544 Ok(t) => t,
4545 Err(err) => {
4546 self.error(format!("{err}; press e to edit"));
4547 self.hand_back(compose_state);
4548 return None;
4549 }
4550 };
4551 let original = match &compose_state.attach {
4552 Some(path) => match std::fs::read(path) {
4553 Ok(bytes) => Some(bytes),
4554 Err(err) => {
4555 self.error(format!("cannot attach the original: {err}"));
4556 self.hand_back(compose_state);
4557 return None;
4558 }
4559 },
4560 None => None,
4561 };
4562 let fcc_security = match self.config.mail.fcc_clear {
4565 true => Security::None,
4566 false => compose_state.security,
4567 };
4568 let fcc_text = match (keep_attachments, fcc_security == compose_state.security) {
4569 (true, true) => Ok(None),
4570 (true, false) => self
4571 .secure_message(
4572 fcc_security,
4573 final_text.clone(),
4574 &files,
4575 original.as_deref(),
4576 )
4577 .map(Some),
4578 (false, _) => self
4579 .secure_message(fcc_security, final_text.clone(), &[], None)
4580 .map(Some),
4581 };
4582 let final_text = self
4583 .secure_message(
4584 compose_state.security,
4585 final_text,
4586 &files,
4587 original.as_deref(),
4588 )
4589 .and_then(|sent| Ok((sent, fcc_text?)));
4590 let (final_text, fcc_text) = match final_text {
4591 Ok(t) => t,
4592 Err(err) => {
4593 self.error(format!("{err:#}; e edits, s changes security"));
4594 self.hand_back(compose_state);
4595 return None;
4596 }
4597 };
4598 let held = Held {
4601 text: final_text,
4602 fcc_text,
4603 fcc: compose_state.fcc.clone().or(hook_fcc),
4604 label: {
4605 let head = raw.split_once("\n\n").map_or(raw.as_str(), |(h, _)| h);
4606 header_value(head, "Subject")
4607 .filter(|s| !s.trim().is_empty())
4608 .or_else(|| header_value(head, "To"))
4609 .unwrap_or_else(|| "message".into())
4610 },
4611 state: compose_state,
4612 due: Instant::now() + Duration::from_secs(self.config.mail.undo_send),
4613 };
4614 if self.config.mail.undo_send > 0 {
4616 self.note(format!(
4617 "sending {} in {}s (z cancels)",
4618 held.label, self.config.mail.undo_send
4619 ));
4620 self.hold_send(held);
4621 return None;
4622 }
4623 self.deliver(held);
4624 None
4625 }
4626
4627 fn confirm_attachment(&mut self) {
4630 self.attach_confirmed = true;
4631 }
4632
4633 fn hand_back(&mut self, draft: Compose) {
4635 self.draft = Some(draft);
4636 self.requests.push(Request::ShowDraft);
4637 }
4638
4639 pub fn draft(&self) -> Option<&Compose> {
4641 self.draft.as_ref()
4642 }
4643
4644 pub fn draft_mut(&mut self) -> Option<&mut Compose> {
4645 self.draft.as_mut()
4646 }
4647
4648 pub fn take_draft(&mut self) -> Option<Compose> {
4651 self.draft.take()
4652 }
4653
4654 pub fn set_draft(&mut self, draft: Compose) {
4661 if let Some((path, staged)) = self.staged.take()
4662 && self.config.mail.abort_unmodified.unwrap_or(true)
4663 && path == draft.path
4664 && std::fs::read_to_string(&draft.path).is_ok_and(|now| now == staged)
4665 {
4666 let _ = std::fs::remove_file(&draft.path);
4667 self.draft = None;
4668 self.error("aborted unmodified message");
4669 return;
4670 }
4671 self.draft = Some(draft);
4672 }
4673
4674 pub fn set_attach_field(&mut self, k: usize, input: &str, is_type: bool) {
4679 let value = input.trim().to_string();
4680 self.edit_draft_head(|head| {
4681 let mut seen = 0usize;
4682 head.lines()
4683 .map(|l| {
4684 let is_attach = l
4685 .split_once(':')
4686 .is_some_and(|(key, _)| key.trim().eq_ignore_ascii_case("attach"));
4687 if is_attach {
4688 let (_, mut atts) = compose::extract_attachments(l);
4691 if let Some(mut a) = atts.pop() {
4692 let idx = seen;
4693 seen += 1;
4694 if idx == k {
4695 let new = (!value.is_empty()).then(|| value.clone());
4696 if is_type {
4697 a.mime = new;
4698 } else {
4699 a.description = new;
4700 }
4701 return compose::attach_line(&a);
4702 }
4703 }
4704 }
4705 l.to_string()
4706 })
4707 .collect::<Vec<_>>()
4708 .join("\n")
4709 });
4710 }
4711
4712 pub fn attach_index(&mut self, sel: usize) -> Option<usize> {
4718 let fixed = 1 + usize::from(self.draft().is_some_and(|c| c.attach.is_some()));
4719 if sel < fixed {
4720 self.error("only Attach: files can be changed here");
4721 return None;
4722 }
4723 Some(sel - fixed)
4724 }
4725
4726 pub fn attachments(&self) -> Vec<compose::Attachment> {
4728 self.draft()
4729 .and_then(|c| draft_full(c).ok())
4730 .map(|full| compose::extract_attachments(&full).1)
4731 .unwrap_or_default()
4732 }
4733
4734 pub fn edit_attachment(&mut self, k: usize, f: impl Fn(&mut compose::Attachment)) {
4736 self.edit_draft_head(|head| {
4737 let mut seen = 0usize;
4738 head.lines()
4739 .map(|l| {
4740 let is_attach = l
4741 .split_once(':')
4742 .is_some_and(|(key, _)| key.trim().eq_ignore_ascii_case("attach"));
4743 if is_attach {
4744 let (_, mut atts) = compose::extract_attachments(l);
4745 if let Some(mut a) = atts.pop() {
4746 let idx = seen;
4747 seen += 1;
4748 if idx == k {
4749 f(&mut a);
4750 return compose::attach_line(&a);
4751 }
4752 }
4753 }
4754 l.to_string()
4755 })
4756 .collect::<Vec<_>>()
4757 .join("\n")
4758 });
4759 }
4760
4761 pub fn toggle_unlink(&mut self, sel: usize) {
4764 let Some(k) = self.attach_index(sel) else {
4765 return;
4766 };
4767 self.edit_attachment(k, |a| a.unlink = !a.unlink);
4768 let on = self.attachments().get(k).is_some_and(|a| a.unlink);
4769 self.note(if on {
4770 "the file will be deleted after sending"
4771 } else {
4772 "the file will be kept after sending"
4773 });
4774 }
4775
4776 pub fn toggle_disposition(&mut self, sel: usize) {
4778 let Some(k) = self.attach_index(sel) else {
4779 return;
4780 };
4781 self.edit_attachment(k, |a| a.inline = !a.inline);
4782 }
4783
4784 pub fn move_attachment(&mut self, sel: usize, up: bool) -> bool {
4787 let Some(k) = self.attach_index(sel) else {
4788 return false;
4789 };
4790 let n = self.attachments().len();
4791 let other = if up {
4792 k.checked_sub(1)
4793 } else {
4794 (k + 1 < n).then_some(k + 1)
4795 };
4796 let Some(other) = other else { return false };
4797 self.edit_draft_head(|head| {
4798 let mut lines: Vec<String> = head.lines().map(String::from).collect();
4799 let attach_rows: Vec<usize> = lines
4800 .iter()
4801 .enumerate()
4802 .filter(|(_, l)| {
4803 l.split_once(':')
4804 .is_some_and(|(key, _)| key.trim().eq_ignore_ascii_case("attach"))
4805 })
4806 .map(|(i, _)| i)
4807 .collect();
4808 if let (Some(&a), Some(&b)) = (attach_rows.get(k), attach_rows.get(other)) {
4809 lines.swap(a, b);
4810 }
4811 lines.join("\n")
4812 });
4813 true
4814 }
4815
4816 fn push_attach_line(&mut self, line: &str) -> std::io::Result<()> {
4818 let Some(c) = self.draft_mut() else {
4819 return Ok(());
4820 };
4821 match &mut c.hidden_head {
4822 Some(head) => {
4823 *head = format!("{}\n{line}", head.trim_end());
4824 Ok(())
4825 }
4826 None => std::fs::read_to_string(&c.path).and_then(|text| {
4827 let updated = match text.split_once("\n\n") {
4828 Some((head, body)) => format!("{head}\n{line}\n\n{body}"),
4829 None => format!("{}\n{line}\n", text.trim_end()),
4830 };
4831 std::fs::write(&c.path, updated)
4832 }),
4833 }
4834 }
4835
4836 pub fn attach_messages(&mut self) {
4842 if self.draft().is_none() {
4843 return;
4844 }
4845 let tagged = self.msgs.iter().any(|m| m.env.tagged);
4846 let paths = self.target_paths(tagged);
4847 let mut attached = 0usize;
4848 for path in paths {
4849 if remote::is_partial(&path) {
4850 self.error(format!(
4851 "{} is not fetched yet: open it first",
4852 path.file_name().and_then(|n| n.to_str()).unwrap_or("?")
4853 ));
4854 continue;
4855 }
4856 let mut a = compose::Attachment::of(path);
4857 a.mime = Some("message/rfc822".into());
4858 a.inline = true;
4859 let line = compose::attach_line(&a);
4860 match self.push_attach_line(&line) {
4861 Ok(()) => attached += 1,
4862 Err(err) => self.error(format!("cannot attach: {err}")),
4863 }
4864 }
4865 if attached > 0 {
4866 self.note(format!("attached {attached} message(s)"));
4867 }
4868 self.requests.push(Request::ShowDraft);
4869 }
4870
4871 pub fn new_mime(&mut self, path: &str, mime: &str) {
4875 if mime.is_empty() {
4876 self.requests.push(Request::ShowDraft);
4877 return;
4878 }
4879 if !mime.contains('/') || mime.starts_with('/') || mime.ends_with('/') {
4880 self.error("Content-Type is of the form base/sub");
4881 self.requests.push(Request::ShowDraft);
4882 return;
4883 }
4884 let file = expand_tilde(path);
4885 if !file.exists()
4886 && let Err(err) = std::fs::write(&file, b"")
4887 {
4888 self.error(format!("cannot create {}: {err}", file.display()));
4889 self.requests.push(Request::ShowDraft);
4890 return;
4891 }
4892 let mut a = compose::Attachment::of(file.clone());
4893 a.mime = Some(mime.to_string());
4894 let line = compose::attach_line(&a);
4895 if let Err(err) = self.push_attach_line(&line) {
4896 self.error(format!("cannot attach: {err}"));
4897 } else {
4898 self.requests.push(Request::EditFile(file));
4899 }
4900 self.requests.push(Request::ShowDraft);
4901 }
4902
4903 pub fn ispell_command(&self) -> Option<String> {
4906 let path = self.draft()?.path.display().to_string();
4907 let ispell = self.config.mail.ispell.as_deref().unwrap_or("ispell");
4908 Some(format!("{ispell} -x '{}'", path.replace('\'', "'\\''")))
4909 }
4910
4911 fn outgoing_text(&self, c: &Compose) -> Result<String> {
4914 let raw = draft_full(c).context("reading the draft")?;
4915 let (raw, files) = compose::extract_attachments(&raw);
4916 let host = maildir::hostname();
4917 let from = self
4918 .current_identity(&[])
4919 .from_line()
4920 .unwrap_or_else(|| default_from(&host));
4921 let msg_host = self
4922 .config
4923 .mail
4924 .hostname
4925 .clone()
4926 .filter(|h| !h.trim().is_empty())
4927 .unwrap_or(host);
4928 let text = compose::finalize_with(
4929 &raw,
4930 &from,
4931 &compose::make_message_id(&msg_host),
4932 &compose::rfc2822_now(),
4933 self.config.mail.user_agent.unwrap_or(false),
4934 )
4935 .map_err(|e| anyhow::anyhow!("{e}"))?;
4936 let original = match &c.attach {
4937 Some(path) => Some(std::fs::read(path).context("reading the original")?),
4938 None => None,
4939 };
4940 self.secure_message(c.security, text, &files, original.as_deref())
4941 }
4942
4943 pub fn write_draft_to(&mut self, mailbox: &str) {
4947 let Some(c) = self.draft() else { return };
4948 let text = match self.outgoing_text(c) {
4949 Ok(t) => t,
4950 Err(err) => {
4951 self.error(format!("cannot write the message: {err:#}"));
4952 return;
4953 }
4954 };
4955 let flags = maildir::Flags {
4956 seen: true,
4957 ..Default::default()
4958 };
4959 match remote::parse_spec(mailbox) {
4960 Some((account, folder)) => {
4961 if !self
4962 .imap
4963 .as_ref()
4964 .is_some_and(|i| i.facts.account.name == account)
4965 {
4966 self.error("write-fcc to IMAP needs a folder of the open account");
4967 return;
4968 }
4969 let mailbox = mailbox.to_string();
4970 self.send_then(
4971 Job::Append {
4972 mailbox: Some(folder.to_string()),
4973 flags,
4974 body: text.into_bytes(),
4975 },
4976 false,
4977 Box::new(move |session, done| match done {
4978 Ok(_) => session.note(format!("Message written to {mailbox}.")),
4979 Err(err) => session.error(format!("write failed: {err:#}")),
4980 }),
4981 );
4982 }
4983 None => {
4984 let dir = expand_tilde(mailbox);
4985 if !dir.join("cur").is_dir() {
4986 self.error(format!("{mailbox} is not a maildir"));
4987 return;
4988 }
4989 match maildir::deliver(&dir, text.as_bytes(), flags) {
4990 Ok(_) => self.note(format!("Message written to {mailbox}.")),
4991 Err(err) => self.error(format!("write failed: {err:#}")),
4992 }
4993 }
4994 }
4995 }
4996
4997 pub fn detach(&mut self, sel: usize) {
4998 let fixed = 1 + usize::from(self.draft().is_some_and(|c| c.attach.is_some()));
4999 if sel < fixed {
5000 self.error("only Attach: files can be detached");
5001 return;
5002 }
5003 let k = sel - fixed;
5004 self.edit_draft_head(|head| {
5005 let mut seen = 0usize;
5006 head.lines()
5007 .filter(|l| {
5008 let is_attach = l
5009 .split_once(':')
5010 .is_some_and(|(key, _)| key.trim().eq_ignore_ascii_case("attach"));
5011 if is_attach {
5012 seen += 1;
5013 seen - 1 != k
5014 } else {
5015 true
5016 }
5017 })
5018 .collect::<Vec<_>>()
5019 .join("\n")
5020 });
5021 }
5022
5023 pub fn attach_file(&mut self, input: &str) {
5027 let input = input.trim();
5028 if !input.is_empty() {
5029 if !expand_tilde(input).is_file() {
5030 self.error(format!("{input} is not a file"));
5031 } else if let Some(c) = self.draft_mut() {
5032 let value = if input.contains(char::is_whitespace) && !input.starts_with('"') {
5035 format!("\"{input}\"")
5036 } else {
5037 input.to_string()
5038 };
5039 let result = match &mut c.hidden_head {
5040 Some(head) => {
5042 *head = format!("{}\nAttach: {value}", head.trim_end());
5043 Ok(())
5044 }
5045 None => std::fs::read_to_string(&c.path).and_then(|text| {
5046 let updated = match text.split_once("\n\n") {
5047 Some((head, body)) => format!("{head}\nAttach: {value}\n\n{body}"),
5048 None => format!("{}\nAttach: {value}\n", text.trim_end()),
5049 };
5050 std::fs::write(&c.path, updated)
5051 }),
5052 };
5053 if let Err(err) = result {
5054 self.error(format!("cannot attach: {err}"));
5055 }
5056 }
5057 }
5058 self.requests.push(Request::ShowDraft);
5059 }
5060
5061 pub fn error(&mut self, msg: impl Into<String>) {
5062 self.notify(Notice::Error(msg.into()));
5063 }
5064
5065 pub fn note(&mut self, msg: impl Into<String>) {
5067 self.notify(Notice::Info(msg.into()));
5068 }
5069
5070 fn notify(&mut self, notice: Notice) {
5075 if let Notice::Error(text) = ¬ice {
5076 let keep = self.config.ui.error_history;
5077 self.error_history.push_back(text.clone());
5078 while self.error_history.len() > keep {
5079 self.error_history.pop_front();
5080 }
5081 }
5082 if (notice.is_error() && self.config.ui.beep)
5085 || (notice.is_new_mail() && self.config.ui.beep_new)
5086 {
5087 use std::io::Write as _;
5088 let mut out = std::io::stdout();
5089 let _ = out.write_all(b"\x07");
5090 let _ = out.flush();
5091 }
5092 self.notices.notice(notice);
5093 self.spoken = true;
5094 }
5095
5096 pub fn notice(&self) -> Option<&Notice> {
5099 self.notices.latest()
5100 }
5101}
5102
5103fn subject_key(subject: &str) -> String {
5105 let mut key = subject.trim().to_lowercase();
5106 loop {
5107 let stripped = key
5108 .strip_prefix("re:")
5109 .or_else(|| key.strip_prefix("fwd:"))
5110 .or_else(|| key.strip_prefix("fw:"))
5111 .map(|rest| rest.trim_start().to_string());
5112 match stripped {
5113 Some(next) => key = next,
5114 None => break,
5115 }
5116 }
5117 key
5118}
5119
5120pub fn parse_sort(spec: &str) -> Option<(SortKey, bool)> {
5122 let (rev, name) = match spec.strip_prefix("reverse-") {
5123 Some(rest) => (true, rest),
5124 None => (false, spec),
5125 };
5126 let key = match name {
5127 "date" | "date-sent" | "date-received" => SortKey::Date,
5128 "from" => SortKey::From,
5129 "subject" => SortKey::Subject,
5130 "size" => SortKey::Size,
5131 "threads" => SortKey::Threads,
5132 "label" => SortKey::Label,
5133 "to" => SortKey::To,
5134 "unsorted" | "mailbox-order" => SortKey::Unsorted,
5135 _ => return None,
5136 };
5137 Some((key, rev && key != SortKey::Threads))
5139}
5140
5141impl pattern::EnvSource for Session {
5142 fn envelope(&self, i: usize) -> Option<&Envelope> {
5143 self.msgs.get(i).map(|m| &m.env)
5144 }
5145}
5146
5147fn dir_mtimes(dir: &Path) -> (Option<SystemTime>, Option<SystemTime>) {
5148 let mtime = |p: PathBuf| p.metadata().and_then(|m| m.modified()).ok();
5149 (mtime(dir.join("new")), mtime(dir.join("cur")))
5150}
5151
5152pub fn account_password(account: &Account) -> Result<String> {
5155 if !matches!(account.auth_kind()?, rmut_core::config::AuthKind::Password) {
5156 return account.secret();
5157 }
5158 static CACHE: Mutex<Option<HashMap<String, String>>> = Mutex::new(None);
5159 let mut cache = CACHE.lock().unwrap();
5160 let map = cache.get_or_insert_with(HashMap::new);
5161 if let Some(password) = map.get(&account.name) {
5162 return Ok(password.clone());
5163 }
5164 let password = account.password()?;
5165 map.insert(account.name.clone(), password.clone());
5166 Ok(password)
5167}
5168
5169pub fn wrap_order(n: usize, sel: usize, forward: bool) -> Vec<(usize, bool)> {
5174 (1..=n)
5175 .map(|step| {
5176 if forward {
5177 ((sel + step) % n, sel + step >= n)
5178 } else {
5179 ((sel + n - (step % n)) % n, step > sel)
5180 }
5181 })
5182 .collect()
5183}
5184
5185pub fn sort_browser(dirs: &mut [(String, usize)], how: Option<&str>) {
5193 let how = how.unwrap_or("alpha");
5194 let (reverse, key) = match how.strip_prefix("reverse-") {
5195 Some(key) => (true, key),
5196 None => (false, how),
5197 };
5198 match key {
5199 "unsorted" => {}
5200 "count" | "unread" => dirs.sort_by_key(|(_, count)| *count),
5201 "date" => dirs.sort_by_cached_key(|(spec, _)| {
5202 if spec.starts_with("imap:") {
5203 return std::time::SystemTime::UNIX_EPOCH;
5204 }
5205 let dir = expand_tilde(spec);
5206 ["new", "cur"]
5207 .iter()
5208 .filter_map(|sub| std::fs::metadata(dir.join(sub)).ok())
5209 .filter_map(|m| m.modified().ok())
5210 .max()
5211 .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
5212 }),
5213 _ => dirs.sort_by(|a, b| a.0.cmp(&b.0)),
5214 }
5215 if reverse {
5216 dirs.reverse();
5217 }
5218}
5219
5220pub fn expand_tilde(input: &str) -> PathBuf {
5221 if let Some(rest) = input.strip_prefix("~/")
5222 && let Ok(home) = std::env::var("HOME")
5223 {
5224 return Path::new(&home).join(rest);
5225 }
5226 PathBuf::from(input)
5227}
5228
5229pub fn send_via_smtp(account: &Account, text: &str, envelope: &smtp::Envelope) -> Result<()> {
5230 let from = compose::from_address(text).context("cannot parse the From address")?;
5231 let (rcpts, text) = compose::smtp_envelope(text)?;
5232 anyhow::ensure!(!rcpts.is_empty(), "no recipient addresses");
5233 let password = account_password(account)?;
5234 smtp::send(account, &password, &from, &rcpts, text.as_bytes(), envelope)
5235}
5236
5237impl Session {
5238 pub fn open_url(&mut self, url: &str) {
5243 match spawn_url_opener(self.config.ui.url_command.as_deref(), url) {
5244 Ok(()) => self.note(format!("opening {url}")),
5245 Err(err) => self.error(format!("{err:#}")),
5246 }
5247 }
5248}
5249
5250pub fn spawn_url_opener(command: Option<&str>, url: &str) -> Result<()> {
5253 let default = if cfg!(target_os = "macos") {
5254 "open"
5255 } else {
5256 "xdg-open"
5257 };
5258 let command = command.filter(|c| !c.trim().is_empty()).unwrap_or(default);
5259 let mut words: Vec<String> = command.split_whitespace().map(String::from).collect();
5260 match words.iter_mut().find(|w| w.contains("%s")) {
5261 Some(word) => *word = word.replace("%s", url),
5262 None => words.push(url.to_string()),
5263 }
5264 let mut child = Command::new(&words[0])
5265 .args(&words[1..])
5266 .stdin(Stdio::null())
5267 .stdout(Stdio::null())
5268 .stderr(Stdio::null())
5269 .spawn()
5270 .with_context(|| format!("cannot run {}", words[0]))?;
5271 std::thread::spawn(move || child.wait());
5272 Ok(())
5273}
5274
5275pub fn pipe_to(command: &str, bytes: &[u8]) -> Result<()> {
5277 let mut child = Command::new("sh")
5278 .arg("-c")
5279 .arg(command)
5280 .stdin(Stdio::piped())
5281 .stdout(Stdio::null())
5282 .stderr(Stdio::null())
5283 .spawn()
5284 .with_context(|| format!("running {command}"))?;
5285 {
5289 let mut stdin = child.stdin.take().context("no stdin on the child")?;
5290 if let Err(err) = stdin.write_all(bytes)
5291 && err.kind() != std::io::ErrorKind::BrokenPipe
5292 {
5293 return Err(err.into());
5294 }
5295 }
5296 let status = child.wait()?;
5297 anyhow::ensure!(status.success(), "{command} exited with {status}");
5298 Ok(())
5299}
5300
5301pub fn run_sendmail(
5305 bytes: &[u8],
5306 configured: Option<&str>,
5307 rcpts: Option<&[String]>,
5308 envelope: &smtp::Envelope,
5309) -> Result<()> {
5310 let command = std::env::var("RMUT_SENDMAIL")
5311 .ok()
5312 .or_else(|| configured.map(String::from));
5313 let (prog, mut args) = match command {
5314 Some(v) => {
5315 let mut it = v.split_whitespace().map(String::from);
5316 let p = it.next().context("sendmail command is empty")?;
5317 (p, it.collect::<Vec<_>>())
5318 }
5319 None => {
5320 let p = if Path::new("/usr/sbin/sendmail").exists() {
5321 "/usr/sbin/sendmail".to_string()
5322 } else {
5323 "sendmail".to_string()
5324 };
5325 (p, Vec::new())
5326 }
5327 };
5328 if let Some(sender) = &envelope.sender {
5329 args.extend(["-f".into(), sender.clone()]);
5330 }
5331 if let Some(notify) = &envelope.notify {
5332 args.extend(["-N".into(), notify.clone()]);
5333 }
5334 if let Some(ret) = &envelope.ret {
5335 args.extend(["-R".into(), ret.clone()]);
5336 }
5337 match rcpts {
5338 Some(rcpts) => {
5339 args.push("-oi".into());
5340 args.extend(rcpts.iter().cloned());
5341 }
5342 None => args.extend(["-t".into(), "-oi".into()]),
5343 }
5344 let mut child = Command::new(&prog)
5345 .args(&args)
5346 .stdin(Stdio::piped())
5347 .stdout(Stdio::null())
5348 .stderr(Stdio::null())
5349 .spawn()
5350 .with_context(|| format!("running {prog}"))?;
5351 child
5352 .stdin
5353 .take()
5354 .context("no stdin on sendmail child")?
5355 .write_all(bytes)?;
5356 let status = child.wait()?;
5357 anyhow::ensure!(status.success(), "{prog} exited with {status}");
5358 Ok(())
5359}
5360
5361pub fn default_from(hostname: &str) -> String {
5362 if let Ok(email) = std::env::var("EMAIL") {
5363 return email;
5364 }
5365 let user = std::env::var("USER").unwrap_or_else(|_| "user".into());
5366 format!("{user}@{hostname}")
5367}
5368
5369const DEFAULT_ATTACH_KEYWORD: &str = r"\b(attach|attached|attaching|attachment|attachments)\b";
5372
5373fn attach_re_from_config(config: &Config, warnings: &mut Vec<String>) -> regex_lite::Regex {
5376 let spec = config
5377 .mail
5378 .attach_keyword
5379 .clone()
5380 .unwrap_or_else(|| DEFAULT_ATTACH_KEYWORD.to_string());
5381 match regex_lite::Regex::new(&format!("(?i){spec}")) {
5382 Ok(re) => re,
5383 Err(err) => {
5384 warnings.push(format!("bad attach_keyword {spec:?}: {err}"));
5385 default_attach_re()
5386 }
5387 }
5388}
5389
5390fn default_attach_re() -> regex_lite::Regex {
5391 regex_lite::Regex::new(&format!("(?i){DEFAULT_ATTACH_KEYWORD}")).expect("the default compiles")
5392}
5393
5394fn reply_re_from_config(config: &Config, warnings: &mut Vec<String>) -> regex_lite::Regex {
5397 match &config.mail.reply_regexp {
5398 Some(spec) => match compose::reply_regexp(spec) {
5399 Ok(re) => re,
5400 Err(err) => {
5401 warnings.push(format!("bad reply_regexp {spec:?}: {err}"));
5402 compose::default_reply_regexp()
5403 }
5404 },
5405 None => compose::default_reply_regexp(),
5406 }
5407}
5408
5409fn quote_re_from_config(config: &Config, warnings: &mut Vec<String>) -> regex_lite::Regex {
5412 match &config.pager.quote_regexp {
5413 Some(spec) => match regex_lite::Regex::new(spec) {
5414 Ok(re) => re,
5415 Err(err) => {
5416 warnings.push(format!("bad quote_regexp {spec:?}: {err}"));
5417 default_quote_re()
5418 }
5419 },
5420 None => default_quote_re(),
5421 }
5422}
5423
5424fn display_from_config(config: &Config) -> message::Display {
5427 let mut rules = message::HeaderRules::default();
5431 let clean = |list: &Vec<String>| {
5432 list.iter()
5433 .map(|n| n.trim_end_matches(':').to_lowercase())
5434 .collect::<Vec<_>>()
5435 };
5436 if let Some(list) = &config.pager.ignore {
5437 rules.ignore = clean(list);
5438 }
5439 if let Some(list) = &config.pager.unignore {
5440 rules.unignore = clean(list);
5441 }
5442 if let Some(list) = &config.pager.hdr_order {
5443 rules.order = clean(list);
5444 }
5445 let mut filters: HashMap<String, String> = HashMap::new();
5450 let mut mailcap: Option<Vec<rmut_core::mailcap::Entry>> = None;
5451 for (mimetype, command) in &config.filters {
5452 let mimetype = mimetype.to_lowercase();
5453 if !command.trim().is_empty() {
5454 filters.insert(mimetype, command.clone());
5455 continue;
5456 }
5457 let entries = mailcap.get_or_insert_with(rmut_core::mailcap::load);
5458 if let Some(command) = rmut_core::mailcap::command_for(entries, &mimetype) {
5459 filters.insert(mimetype, command);
5460 }
5461 }
5462 message::Display {
5463 filters,
5464 rules,
5465 reflow: config.pager.reflow_text.unwrap_or(true),
5466 alternative_order: config
5467 .pager
5468 .alternative_order
5469 .iter()
5470 .map(|t| t.to_lowercase())
5471 .collect(),
5472 html_to_text: config.pager.html.as_deref() != Some("raw"),
5473 }
5474}
5475
5476fn compile_hooks<'a>(
5479 what: &str,
5480 entries: impl Iterator<Item = (&'a String, &'a String)>,
5481 warnings: &mut Vec<String>,
5482) -> Vec<Hook> {
5483 let mut out = Vec::new();
5484 for (spec, value) in entries {
5485 if value.trim().is_empty() {
5486 warnings.push(format!("{what} {spec:?} has nothing to do"));
5487 continue;
5488 }
5489 match pattern::parse(spec) {
5490 Ok(patterns) => out.push(Hook {
5491 patterns,
5492 value: value.clone(),
5493 }),
5494 Err(err) => warnings.push(format!("bad {what} pattern {spec:?}: {err}")),
5495 }
5496 }
5497 out
5498}
5499
5500pub fn header_value(head: &str, name: &str) -> Option<String> {
5502 head.lines().find_map(|l| {
5503 let (k, v) = l.split_once(':')?;
5504 k.trim()
5505 .eq_ignore_ascii_case(name)
5506 .then(|| v.trim().to_string())
5507 })
5508}
5509
5510pub fn draft_full(c: &Compose) -> std::io::Result<String> {
5513 let text = std::fs::read_to_string(&c.path)?;
5514 Ok(match &c.hidden_head {
5515 Some(head) => format!("{}\n\n{}", head.trim_end(), text),
5516 None => text,
5517 })
5518}
5519
5520pub fn default_quote_re() -> regex_lite::Regex {
5522 regex_lite::Regex::new(r"^([ \t]*[|>:}#])+").expect("default quote_regexp compiles")
5523}
5524
5525pub fn write_draft(text: &str) -> Result<PathBuf> {
5526 use std::sync::atomic::{AtomicUsize, Ordering};
5527 static COUNTER: AtomicUsize = AtomicUsize::new(0);
5528 let path = std::env::temp_dir().join(format!(
5529 "rmut-draft-{}-{}.eml",
5530 std::process::id(),
5531 COUNTER.fetch_add(1, Ordering::Relaxed),
5532 ));
5533 std::fs::write(&path, text).with_context(|| format!("writing {}", path.display()))?;
5534 Ok(path)
5535}