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, sent: Sent },
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
308#[derive(Default)]
312struct Sent {
313 flags: HashMap<PathBuf, maildir::Flags>,
314 deletes: std::collections::HashSet<PathBuf>,
315}
316
317type Then = Box<dyn FnOnce(&mut Session, Result<Done>) + Send>;
320
321enum Deferred {
323 Again(Again),
325 Job(Job, Pending),
327}
328
329impl Deferred {
330 fn holds(&self) -> bool {
331 matches!(self, Deferred::Job(_, pending) if pending.holds())
332 }
333
334 fn outlives_abort(&self) -> bool {
337 matches!(self, Deferred::Job(_, Pending::Then { hold: false, .. }))
338 }
339}
340
341struct Opening {
344 spec: String,
345 read_only: bool,
346 answer: Receiver<Result<(Session, Vec<String>)>>,
347 progress: Arc<Mutex<Option<String>>>,
348}
349
350struct Copied {
352 copied: Vec<usize>,
354 errors: Vec<String>,
355 created: Vec<PathBuf>,
357 target: String,
359 note: Option<String>,
361 delete: bool,
362 tagged: bool,
363}
364
365#[derive(Clone)]
368enum Again {
369 View,
371 Copy {
372 input: String,
373 delete: bool,
374 tagged: bool,
375 decode: bool,
376 },
377 Pipe {
378 command: String,
379 tagged: bool,
380 },
381 Print {
382 tagged: bool,
383 },
384 Bounce {
385 to: String,
386 tagged: bool,
387 },
388}
389
390pub struct Session {
392 pub dir: PathBuf,
395 pub title: String,
398 imap: Option<Imap>,
402 pub mbox: Option<mbox::Mbox>,
404 pub msgs: Vec<Msg>,
405 pub visible: Vec<usize>,
407 pub sel: usize,
409 pub sort: SortKey,
410 pub sort_rev: bool,
411 pub limit: Option<(String, Vec<Pattern>)>,
412 pub last_search: Option<Vec<Pattern>>,
413 pub search_rev: bool,
416 pub thread_depth: Vec<usize>,
419 pub thread_root: Vec<usize>,
420 pub thread_pseudo: Vec<bool>,
423 pub thread_parent: Vec<Option<usize>>,
427 pub thread_children: Vec<Vec<usize>>,
428 pub thread_members: Vec<Vec<usize>>,
429 pub collapsed: HashSet<PathBuf>,
431 undo: Vec<UndoStep>,
434 pub config: Config,
435 pub my_addresses: Vec<String>,
438 pub lists: Vec<pattern::Matcher>,
441 pub subscribed: Vec<pattern::Matcher>,
444 pub alternates: Vec<pattern::Matcher>,
447 body_hits: HashMap<String, HashSet<u32>>,
450 body_local: HashSet<String>,
452 server_folders: Option<Vec<(String, usize)>>,
455 listing: bool,
457 pub idle: Option<remote::IdleWatch>,
459 backfill: Option<remote::Backfill>,
461 mailbox_new: HashMap<String, usize>,
464 error_history: std::collections::VecDeque<String>,
467 mailbox_announced: HashSet<String>,
470 unseen: HashMap<String, usize>,
473 pub read_only: bool,
475 pub read_only_session: bool,
479 pub(crate) dir_mtimes: (Option<SystemTime>, Option<SystemTime>),
481 pub message_hooks: Vec<Hook>,
486 pub reply_hooks: Vec<Hook>,
487 fcc_hooks: Vec<Hook>,
488 crypt_hooks: Vec<(pattern::Matcher, String)>,
489 pub display: message::Display,
492 outbox: Vec<Held>,
495 pub quote_re: regex_lite::Regex,
498 pub reply_re: regex_lite::Regex,
500 attach_re: regex_lite::Regex,
502 pending: Option<Pending>,
505 deferred: std::collections::VecDeque<Deferred>,
509 opening: Option<Opening>,
512 progress_noted: bool,
515 spoken: bool,
519 aborted: bool,
522 quit_default: bool,
525 setup: Option<ComposeSetup>,
528 draft: Option<Compose>,
532 staged: Option<(PathBuf, String)>,
536 parked_forward: Option<Compose>,
539 fcc_attach_answer: Option<bool>,
541 attach_confirmed: bool,
544 hook_base: Option<Box<Config>>,
547 active_message_hooks: Vec<usize>,
550 requests: Vec<Request>,
552 notices: Box<dyn NoticeSink>,
555}
556
557struct Silence;
559
560impl NoticeSink for Silence {
561 fn notice(&mut self, _notice: Notice) {}
562
563 fn latest(&self) -> Option<&Notice> {
564 None
565 }
566
567 fn clear(&mut self) {}
568}
569
570impl Session {
571 pub fn open(dir: &Path, config: Config) -> Result<(Session, Vec<String>)> {
577 let (envelopes, skipped) = hdrcache::load_envelopes(dir)?;
579 let mut msgs: Vec<Msg> = envelopes
580 .into_iter()
581 .map(|env| Msg {
582 env,
583 dirty: false,
584 purge: false,
585 })
586 .collect();
587 msgs.sort_by_key(|m| m.env.date);
589 let visible: Vec<usize> = (0..msgs.len()).collect();
590 let sel = msgs
592 .iter()
593 .position(|m| m.env.file.is_new)
594 .unwrap_or(visible.len().saturating_sub(1));
595 let mut warnings = Vec::new();
596 if skipped > 0 {
597 warnings.push(format!("{skipped} unreadable message(s) skipped"));
598 }
599 let count = msgs.len();
600 let mut me: Vec<String> = config
601 .identity
602 .email
603 .iter()
604 .chain(config.accounts.iter().map(|a| &a.user))
605 .chain(
606 config
607 .accounts
608 .iter()
609 .filter_map(|a| a.identity.as_ref().and_then(|i| i.email.as_ref())),
610 )
611 .chain(config.identities.iter().filter_map(|r| r.email.as_ref()))
612 .map(|a| a.to_lowercase())
613 .collect();
614 if let Ok(email) = std::env::var("EMAIL") {
615 me.push(email.to_lowercase());
616 }
617 let mut session = Session {
618 dir: dir.to_path_buf(),
619 title: dir.display().to_string(),
620 imap: None,
621 mbox: None,
622 msgs,
623 visible,
624 sel,
625 sort: SortKey::Date,
626 sort_rev: false,
627 limit: None,
628 last_search: None,
629 search_rev: false,
630 thread_depth: vec![0; count],
631 thread_root: (0..count).collect(),
632 thread_pseudo: vec![false; count],
633 thread_parent: vec![None; count],
634 thread_children: vec![Vec::new(); count],
635 thread_members: (0..count).map(|i| vec![i]).collect(),
636 collapsed: HashSet::new(),
637 undo: Vec::new(),
638 lists: config.list_matchers(),
639 subscribed: config.subscribed_matchers(),
640 alternates: config.alternate_matchers(),
641 my_addresses: me,
642 body_hits: HashMap::new(),
643 body_local: HashSet::new(),
644 server_folders: None,
645 listing: false,
646 idle: None,
647 backfill: None,
648 mailbox_new: HashMap::new(),
649 error_history: std::collections::VecDeque::new(),
650 mailbox_announced: HashSet::new(),
651 unseen: HashMap::new(),
652 read_only: false,
653 read_only_session: false,
654 dir_mtimes: dir_mtimes(dir),
655 message_hooks: Vec::new(),
656 reply_hooks: Vec::new(),
657 fcc_hooks: Vec::new(),
658 crypt_hooks: Vec::new(),
659 display: display_from_config(&config),
660 outbox: Vec::new(),
661 quote_re: default_quote_re(),
662 reply_re: compose::default_reply_regexp(),
663 attach_re: default_attach_re(),
664 config,
665 pending: None,
666 deferred: std::collections::VecDeque::new(),
667 opening: None,
668 progress_noted: false,
669 spoken: false,
670 aborted: false,
671 quit_default: true,
672 setup: None,
673 draft: None,
674 staged: None,
675 parked_forward: None,
676 fcc_attach_answer: None,
677 attach_confirmed: false,
678 hook_base: None,
679 active_message_hooks: Vec::new(),
680 requests: Vec::new(),
681 notices: Box::new(Silence),
682 };
683 session.apply_timeouts();
684 let mut hook_warnings = Vec::new();
685 session.quote_re = quote_re_from_config(&session.config, &mut hook_warnings);
686 session.reply_re = reply_re_from_config(&session.config, &mut hook_warnings);
687 session.attach_re = attach_re_from_config(&session.config, &mut hook_warnings);
688 session.compile_hooks_from_config(&mut hook_warnings);
689 hook_warnings.append(&mut warnings);
690 let mut warnings = hook_warnings;
691 if let Some(spec) = session.config.index.sort.clone() {
692 match parse_sort(&spec) {
693 Some((sort, rev)) => {
694 session.sort = sort;
695 session.sort_rev = rev;
696 session.resort(None);
697 }
698 None => warnings.push(format!("unknown sort {spec:?} in config")),
699 }
700 }
701 Ok((session, warnings))
702 }
703
704 pub fn open_spec(
711 spec: &str,
712 config: Config,
713 mut progress: remote::Progress,
714 ) -> Result<(Session, Vec<String>)> {
715 rmut_core::net::set_timeouts(config.net.connect_timeout, config.net.timeout);
718 rmut_core::net::set_trust(
719 config.net.system_cas,
720 config.net.certificate_file.as_deref().map(expand_tilde),
721 );
722 match remote::parse_spec(spec) {
723 Some((account_name, mailbox)) => {
724 let account = config
725 .account(account_name)
726 .with_context(|| format!("no account {account_name} in config"))?
727 .clone();
728 let password = account_password_saying(&account, &mut progress)?;
729 Session::open_remote(&account, mailbox, &password, config, progress)
730 }
731 None => {
732 let path = expand_tilde(spec);
733 if path.is_file() {
734 return Session::open_mbox(&path, config);
735 }
736 Session::open(&path, config)
737 }
738 }
739 }
740
741 fn open_remote(
746 account: &Account,
747 mailbox: &str,
748 password: &str,
749 config: Config,
750 progress: remote::Progress,
751 ) -> Result<(Session, Vec<String>)> {
752 let remote = Remote::open(account, mailbox, password, progress)?;
753 let cache = remote.cache.clone();
754 let (mut session, warnings) = Session::open(&cache, config)?;
755 session.title = remote.spec.clone();
756 session.idle = Some(remote::idle_watch(account, &remote.mailbox, password));
759 session.imap = Some(Imap::new(remote));
760 Ok((session, warnings))
761 }
762
763 pub fn open_mbox(path: &Path, config: Config) -> Result<(Session, Vec<String>)> {
766 let mbox = mbox::Mbox::open(path)?;
767 let cache = mbox.cache.clone();
768 let (mut session, warnings) = Session::open(&cache, config)?;
769 session.title = path.display().to_string();
770 session.mbox = Some(mbox);
771 Ok((session, warnings))
772 }
773
774 pub fn recompile(&mut self) -> Vec<String> {
777 let mut warnings = Vec::new();
778 self.apply_timeouts();
779 self.display = display_from_config(&self.config);
780 self.quote_re = quote_re_from_config(&self.config, &mut warnings);
781 self.reply_re = reply_re_from_config(&self.config, &mut warnings);
782 self.attach_re = attach_re_from_config(&self.config, &mut warnings);
783 self.compile_hooks_from_config(&mut warnings);
784 self.lists = self.config.list_matchers();
785 self.subscribed = self.config.subscribed_matchers();
786 self.alternates = self.config.alternate_matchers();
787 warnings
788 }
789
790 fn apply_timeouts(&self) {
794 rmut_core::net::set_timeouts(self.config.net.connect_timeout, self.config.net.timeout);
795 rmut_core::net::set_trust(
796 self.config.net.system_cas,
797 self.config
798 .net
799 .certificate_file
800 .as_deref()
801 .map(expand_tilde),
802 );
803 }
804
805 fn compile_hooks_from_config(&mut self, warnings: &mut Vec<String>) {
808 self.message_hooks = compile_hooks(
809 "message-hook",
810 self.config
811 .message_hooks
812 .iter()
813 .map(|h| (&h.pattern, &h.command)),
814 warnings,
815 );
816 self.reply_hooks = compile_hooks(
817 "reply-hook",
818 self.config
819 .reply_hooks
820 .iter()
821 .map(|h| (&h.pattern, &h.command)),
822 warnings,
823 );
824 self.fcc_hooks = compile_hooks(
825 "fcc-hook",
826 self.config
827 .fcc_hooks
828 .iter()
829 .map(|h| (&h.pattern, &h.mailbox)),
830 warnings,
831 );
832 self.crypt_hooks = self
833 .config
834 .crypt_hooks
835 .iter()
836 .map(|h| (pattern::Matcher::new(&h.address), h.key.clone()))
837 .collect();
838 }
839
840 pub fn switch_to(&mut self, spec: &str, read_only: bool) {
856 if self.holding() {
857 self.error("still opening the last one (Ctrl+G gives up on it)");
858 return;
859 }
860 let Some((account, mailbox)) = remote::parse_spec(spec) else {
861 let config = self.config.clone();
862 match Session::open_spec(spec, config, Box::new(|_| {})) {
863 Ok((next, warnings)) => self.become_(next, warnings, read_only),
864 Err(err) => self.error(format!("cannot open {spec}: {err:#}")),
865 }
866 return;
867 };
868 let same_account = self
869 .imap
870 .as_ref()
871 .is_some_and(|imap| imap.facts.account.name == account);
872 if !same_account {
873 self.open_in_background(spec, read_only);
874 return;
875 }
876 let spec = spec.to_string();
877 self.send_then(
878 Job::Switch(mailbox.into()),
879 true,
880 Box::new(move |session, done| match done {
881 Ok(_) => session.switched(read_only),
882 Err(_) => session.open_in_background(&spec, read_only),
885 }),
886 );
887 }
888
889 pub fn holding(&self) -> bool {
893 self.opening.is_some()
894 || self.pending.as_ref().is_some_and(Pending::holds)
895 || self.deferred.iter().any(Deferred::holds)
896 }
897
898 fn switched(&mut self, read_only: bool) {
901 let Some(imap) = self.imap.take() else {
902 return;
903 };
904 match Session::open(&imap.facts.cache.clone(), self.config.clone()) {
905 Ok((mut next, warnings)) => {
906 next.title = imap.facts.spec.clone();
907 if let Ok(password) = account_password(&imap.facts.account) {
909 next.idle = Some(remote::idle_watch(
910 &imap.facts.account,
911 &imap.facts.mailbox,
912 &password,
913 ));
914 }
915 next.imap = Some(imap);
916 next.deferred = mem::take(&mut self.deferred)
919 .into_iter()
920 .filter(Deferred::outlives_abort)
921 .collect();
922 self.become_(next, warnings, read_only);
923 }
924 Err(err) => self.error(format!("cannot open {}: {err:#}", imap.facts.spec)),
925 }
926 }
927
928 fn open_in_background(&mut self, spec: &str, read_only: bool) {
931 let Some((account_name, mailbox)) = remote::parse_spec(spec) else {
932 return;
933 };
934 let prepared = self
935 .config
936 .account(account_name)
937 .with_context(|| format!("no account {account_name} in config"))
938 .cloned()
939 .and_then(|account| account_password(&account).map(|password| (account, password)));
940 let (account, password) = match prepared {
941 Ok(prepared) => prepared,
942 Err(err) => {
943 self.error(format!("cannot open {spec}: {err:#}"));
944 return;
945 }
946 };
947 let progress = Arc::new(Mutex::new(None));
948 let (tell, answer) = std::sync::mpsc::channel();
949 let config = self.config.clone();
950 let mailbox = mailbox.to_string();
951 let sink = Imap::progress_sink(&progress);
952 std::thread::spawn(move || {
953 let opened = Session::open_remote(&account, &mailbox, &password, config, sink);
954 let _ = tell.send(opened);
956 });
957 self.note_progress("opening the folder... (Ctrl+G aborts)".into());
958 self.opening = Some(Opening {
959 spec: spec.to_string(),
960 read_only,
961 answer,
962 progress,
963 });
964 }
965
966 fn become_(&mut self, mut next: Session, warnings: Vec<String>, read_only: bool) {
971 if self.sync_queued() {
974 self.wind_down();
975 if self.pending_count() > 0 {
976 return; }
978 }
979 next.read_only_session = self.read_only_session;
982 next.read_only = self.read_only_session || read_only;
983 next.notices = mem::replace(&mut self.notices, Box::new(Silence));
984 next.outbox = mem::take(&mut self.outbox);
985 *self = next;
986 self.maybe_backfill();
988 self.requests.push(Request::Opened(warnings));
989 }
990
991 pub fn install_notices(&mut self, sink: Box<dyn NoticeSink>) {
994 self.notices = sink;
995 }
996
997 pub fn clear_notice(&mut self) {
1000 self.notices.clear();
1001 self.progress_noted = false;
1003 self.spoken = false;
1004 }
1005
1006 pub fn new_count(&self) -> usize {
1007 self.msgs.iter().filter(|m| m.env.file.is_new).count()
1008 }
1009
1010 pub fn deleted_count(&self) -> usize {
1011 self.msgs
1012 .iter()
1013 .filter(|m| m.env.file.flags.deleted)
1014 .count()
1015 }
1016
1017 pub fn pending_count(&self) -> usize {
1018 self.msgs.iter().filter(|m| m.pending()).count()
1019 }
1020
1021 fn thread_has_unread(&self, root: usize) -> bool {
1023 self.thread_root
1024 .iter()
1025 .enumerate()
1026 .filter(|&(_, &r)| r == root)
1027 .any(|(i, _)| !self.msgs[i].env.file.flags.seen)
1028 }
1029
1030 fn first_unread_in_thread(&self, root: usize) -> Option<PathBuf> {
1033 self.thread_root
1034 .iter()
1035 .enumerate()
1036 .filter(|&(_, &r)| r == root)
1037 .map(|(i, _)| i)
1038 .find(|&i| !self.msgs[i].env.file.flags.seen)
1039 .map(|i| self.msgs[i].env.file.path.clone())
1040 }
1041
1042 pub fn thread_info(&self, mi: usize) -> (usize, Option<usize>) {
1044 if self.sort != SortKey::Threads {
1045 return (0, None);
1046 }
1047 let depth = self.thread_depth.get(mi).copied().unwrap_or(0);
1048 if depth == 0 && self.collapsed.contains(&self.msgs[mi].env.file.path) {
1049 let hidden = self
1050 .thread_root
1051 .iter()
1052 .enumerate()
1053 .filter(|&(j, &r)| r == mi && j != mi)
1054 .count();
1055 if hidden > 0 {
1056 return (0, Some(hidden));
1057 }
1058 }
1059 (depth, None)
1060 }
1061
1062 pub fn select(&mut self, index: usize) {
1063 if self.visible.is_empty() {
1064 return;
1065 }
1066 self.sel = index.min(self.visible.len() - 1);
1067 }
1068
1069 pub fn cur_mut(&mut self) -> Option<&mut Msg> {
1070 let i = self.visible.get(self.sel).copied()?;
1071 self.msgs.get_mut(i)
1072 }
1073
1074 pub fn selected_path(&self) -> Option<PathBuf> {
1075 self.visible
1076 .get(self.sel)
1077 .map(|&i| self.msgs[i].env.file.path.clone())
1078 }
1079
1080 pub fn deny_readonly(&mut self) -> bool {
1083 if self.read_only {
1084 self.error("Mailbox is read-only.");
1085 }
1086 self.read_only
1087 }
1088
1089 pub fn mark_read(&mut self) {
1090 if self.read_only {
1091 return;
1092 }
1093 if let Some(m) = self.cur_mut()
1094 && (m.env.file.is_new || !m.env.file.flags.seen)
1095 {
1096 m.env.file.is_new = false;
1097 m.env.file.flags.seen = true;
1098 m.dirty = true;
1099 }
1100 }
1101
1102 pub fn scope(&self, position: pattern::Position) -> pattern::Scope<'_> {
1105 pattern::Scope {
1106 me: self.me(),
1107 lists: &self.lists,
1108 subscribed: &self.subscribed,
1109 position,
1110 thread: None,
1111 }
1112 }
1113
1114 pub fn scope_at(&self, position: pattern::Position, mi: usize) -> pattern::Scope<'_> {
1118 let thread = (self.sort == SortKey::Threads && mi < self.msgs.len()).then(|| {
1119 let root = self.thread_root.get(mi).copied().unwrap_or(mi);
1120 pattern::ThreadView {
1121 members: self.thread_members.get(root).map_or(&[][..], Vec::as_slice),
1122 parent: self.thread_parent.get(mi).copied().flatten(),
1123 children: self.thread_children.get(mi).map_or(&[][..], Vec::as_slice),
1124 collapsed: self.thread_depth.get(mi) == Some(&0)
1125 && self.collapsed.contains(&self.msgs[mi].env.file.path),
1126 envs: self,
1127 }
1128 });
1129 pattern::Scope {
1130 thread,
1131 ..self.scope(position)
1132 }
1133 }
1134
1135 pub fn me(&self) -> pattern::Me<'_> {
1137 pattern::Me::new(&self.my_addresses, &self.alternates)
1138 }
1139
1140 fn env_matches_at(
1145 &self,
1146 patterns: &[Pattern],
1147 env: &Envelope,
1148 pos: pattern::Position,
1149 mi: usize,
1150 ) -> bool {
1151 pattern::matches_in(
1152 patterns,
1153 env,
1154 self.scope_at(pos, mi),
1155 Some(&|env: &Envelope, m: &pattern::Matcher| {
1156 let set = self.body_hits.get(m.raw())?;
1157 let uid = remote::uid_of(&env.file.path)?;
1158 Some(set.contains(&uid))
1159 }),
1160 )
1161 }
1162
1163 fn positions(&self) -> Vec<pattern::Position> {
1168 let mut seen: HashMap<&str, usize> = HashMap::new();
1169 for m in &self.msgs {
1170 if let Some(id) = m.env.msg_id.as_deref() {
1171 *seen.entry(id).or_default() += 1;
1172 }
1173 }
1174 let mut numbers = vec![0usize; self.msgs.len()];
1175 for (n, &mi) in self.visible.iter().enumerate() {
1176 if let Some(slot) = numbers.get_mut(mi) {
1177 *slot = n + 1;
1178 }
1179 }
1180 let current = self
1181 .visible
1182 .get(self.sel)
1183 .and_then(|&mi| numbers.get(mi).copied())
1184 .unwrap_or(0);
1185 let last = self.visible.len();
1186 (0..self.msgs.len())
1187 .map(|i| pattern::Position {
1188 number: numbers[i],
1189 current,
1190 last,
1191 duplicate: self.msgs[i]
1192 .env
1193 .msg_id
1194 .as_deref()
1195 .is_some_and(|id| seen.get(id).copied().unwrap_or(0) > 1),
1196 })
1197 .collect()
1198 }
1199
1200 fn body_terms_ready(
1210 &mut self,
1211 patterns: &[Pattern],
1212 redo: Box<dyn FnOnce(&mut Session) + Send>,
1213 ) -> bool {
1214 if self.imap.is_none() {
1215 return true;
1216 }
1217 let Some(term) = pattern::body_terms(patterns).into_iter().find(|term| {
1218 let simple = !term
1219 .chars()
1220 .any(|c| r".*+?[](){}|^$\".contains(c) || !c.is_ascii());
1221 simple && !self.body_hits.contains_key(term) && !self.body_local.contains(term)
1222 }) else {
1223 return true;
1224 };
1225 self.send_then(
1226 Job::SearchBody(term.clone()),
1227 true,
1228 Box::new(move |session, done| {
1229 match done {
1230 Ok(Done::Uids(uids)) => {
1231 session.body_hits.insert(term, uids.into_iter().collect());
1232 }
1233 _ => {
1234 session.body_local.insert(term);
1235 }
1236 }
1237 redo(session);
1238 }),
1239 );
1240 false
1241 }
1242
1243 pub fn rescan(&mut self) {
1246 let Ok(files) = maildir::scan(&self.dir) else {
1247 return;
1248 };
1249 let keep = self.selected_path();
1250 let mut old: std::collections::HashMap<PathBuf, Msg> = self
1251 .msgs
1252 .drain(..)
1253 .map(|m| (m.env.file.path.clone(), m))
1254 .collect();
1255 let mut arrived = 0usize;
1256 let mut arrivals: Vec<PathBuf> = Vec::new();
1257 for file in files {
1258 match old.remove(&file.path) {
1259 Some(prev) if prev.pending() => self.msgs.push(prev),
1260 Some(mut prev) => {
1261 prev.env.file = file;
1263 self.msgs.push(prev);
1264 }
1265 None => {
1266 if let Ok(env) = message::envelope(file) {
1267 if env.file.is_new {
1268 arrived += 1;
1269 arrivals.push(env.file.path.clone());
1270 }
1271 self.msgs.push(Msg {
1272 env,
1273 dirty: false,
1274 purge: false,
1275 });
1276 }
1277 }
1278 }
1279 }
1280 self.dir_mtimes = dir_mtimes(&self.dir);
1281 self.resort(keep.clone());
1282 if self.sort == SortKey::Threads
1285 && self.config.index.uncollapse_new.unwrap_or(true)
1286 && !self.collapsed.is_empty()
1287 {
1288 let mut unfolded = false;
1289 for path in &arrivals {
1290 if let Some(mi) = self.msgs.iter().position(|m| &m.env.file.path == path) {
1291 let root = self.thread_root.get(mi).copied().unwrap_or(mi);
1292 unfolded |= self.collapsed.remove(&self.msgs[root].env.file.path);
1293 }
1294 }
1295 if unfolded {
1296 self.rebuild_visible(keep);
1297 }
1298 }
1299 if arrived > 0 {
1300 let title = self.title.clone();
1301 self.notify(Notice::NewMail(format!("new mail in {title} (+{arrived})")));
1302 self.run_new_mail_command(&title, arrived);
1303 }
1304 }
1305
1306 fn run_new_mail_command(&self, mailbox: &str, count: usize) {
1309 let Some(cmd) = &self.config.mail.new_mail_command else {
1310 return;
1311 };
1312 let cmd = cmd
1313 .replace("%f", &format!("'{}'", mailbox.replace('\'', r"'\''")))
1314 .replace("%n", &count.to_string());
1315 let _ = Command::new("sh")
1316 .arg("-c")
1317 .arg(cmd)
1318 .stdout(Stdio::null())
1319 .stderr(Stdio::null())
1320 .spawn();
1321 }
1322
1323 pub fn check_new_mail(&mut self) {
1330 if self.pending.is_some() {
1333 return;
1334 }
1335 let backfilling = self.backfill.as_ref().is_some_and(|b| !b.done());
1336 if !backfilling && self.start(Job::CheckNew, Pending::CheckNew) {
1340 return;
1341 }
1342 self.check_local_mail();
1343 }
1344
1345 fn check_local_mail(&mut self) {
1348 self.maybe_backfill();
1349 if let Some(mbox) = &mut self.mbox {
1350 if let Err(err) = mbox.refresh() {
1352 self.error(format!("mbox: {err:#}"));
1353 }
1354 }
1355 self.check_other_mailboxes();
1356 self.requests.push(Request::MailboxesChanged);
1358 let check_new = self.config.mail.check_new.unwrap_or(true) || self.imap.is_some();
1362 if check_new && dir_mtimes(&self.dir) != self.dir_mtimes {
1363 self.rescan();
1364 }
1365 if self.deferred.is_empty() {
1368 self.refresh_unseen();
1369 }
1370 }
1371
1372 pub fn poll_network(&mut self) {
1376 self.poll_opening();
1377 if let Some(line) = self.imap.as_mut().and_then(Imap::take_progress) {
1378 if !self.aborted {
1381 self.note_progress(format!("{line} (Ctrl+G aborts)"));
1382 }
1383 }
1384 let Some(done) = self.imap.as_mut().and_then(Imap::collect) else {
1385 return;
1386 };
1387 if mem::take(&mut self.progress_noted) {
1390 self.clear_notice();
1391 }
1392 if let Some(pending) = self.pending.take() {
1393 self.resume(pending, done);
1394 }
1395 while self.pending.is_none()
1398 && let Some(next) = self.deferred.pop_front()
1399 {
1400 match next {
1401 Deferred::Again(again) => self.run_again(again),
1402 Deferred::Job(job, pending) => {
1403 self.start(job, pending);
1404 }
1405 }
1406 }
1407 }
1408
1409 fn poll_opening(&mut self) {
1412 let Some(opening) = &self.opening else {
1413 return;
1414 };
1415 let line = opening
1416 .progress
1417 .lock()
1418 .ok()
1419 .and_then(|mut slot| slot.take());
1420 let answer = match opening.answer.try_recv() {
1421 Ok(answer) => answer,
1422 Err(TryRecvError::Empty) => {
1423 if let Some(line) = line {
1424 self.note_progress(format!("{line} (Ctrl+G aborts)"));
1425 }
1426 return;
1427 }
1428 Err(TryRecvError::Disconnected) => Err(anyhow::anyhow!("the connection is gone")),
1429 };
1430 let Some(opening) = self.opening.take() else {
1431 return;
1432 };
1433 if mem::take(&mut self.progress_noted) {
1434 self.clear_notice();
1435 }
1436 match answer {
1437 Ok((next, warnings)) => self.become_(next, warnings, opening.read_only),
1438 Err(err) => self.error(format!("cannot open {}: {err:#}", opening.spec)),
1439 }
1440 }
1441
1442 pub fn busy(&self) -> Option<&'static str> {
1444 if self.opening.is_some() {
1445 return Some("opening the folder");
1446 }
1447 self.imap.as_ref().and_then(Imap::busy)
1448 }
1449
1450 pub fn abort_network(&mut self) {
1454 let Some(what) = self.busy() else {
1455 return;
1456 };
1457 if self.opening.take().is_some() {
1458 self.note(format!("aborted: {what}"));
1461 self.progress_noted = false;
1462 return;
1463 }
1464 if let Some(imap) = &self.imap {
1465 imap.abort();
1466 }
1467 self.note(format!("aborted: {what}"));
1468 self.deferred.retain(Deferred::outlives_abort);
1471 self.listing = false;
1472 self.progress_noted = false;
1475 self.aborted = true;
1476 }
1477
1478 fn start(&mut self, job: Job, pending: Pending) -> bool {
1485 if self.imap.is_none() {
1486 return false;
1487 }
1488 if self.pending.is_some() {
1489 self.deferred.push_back(Deferred::Job(job, pending));
1490 return true;
1491 }
1492 let Some(imap) = &mut self.imap else {
1493 return false;
1494 };
1495 let what = job.what();
1496 match imap.start(job) {
1497 Ok(()) => {
1498 self.note_progress(format!("{what}... (Ctrl+G aborts)"));
1502 self.pending = Some(pending);
1503 true
1504 }
1505 Err(err) => {
1506 self.error(format!("imap: {err:#}"));
1507 false
1508 }
1509 }
1510 }
1511
1512 fn note_progress(&mut self, line: String) {
1516 if self.progress_noted || !self.spoken {
1517 self.note(line);
1518 self.spoken = false;
1519 self.progress_noted = true;
1520 }
1521 }
1522
1523 fn send_then(&mut self, job: Job, hold: bool, then: Then) {
1527 if self.imap.is_none() {
1528 then(self, Err(anyhow::anyhow!("not connected")));
1529 return;
1530 }
1531 self.start(job, Pending::Then { hold, then });
1534 }
1535
1536 fn settle(&mut self) {
1539 while let Some(pending) = self.pending.take() {
1540 let Some(imap) = &mut self.imap else { break };
1541 let done = imap.wait();
1542 if mem::take(&mut self.progress_noted) {
1543 self.clear_notice();
1544 }
1545 self.resume(pending, done);
1546 }
1547 }
1548
1549 fn resume(&mut self, pending: Pending, done: Result<Done>) {
1550 if mem::take(&mut self.aborted) && done.is_err() {
1551 return;
1554 }
1555 match pending {
1556 Pending::CheckNew => {
1557 if let Err(err) = done {
1558 self.error(format!("imap: {err:#}"));
1559 }
1560 self.check_local_mail();
1561 }
1562 Pending::Sync { purge, sent } => match done {
1563 Err(err) => {
1567 self.error(format!("sync failed: {err:#}"));
1568 self.deferred.retain(|d| !d.holds());
1569 }
1570 Ok(_) => self.finish_sync(purge, Some(&sent)),
1571 },
1572 Pending::Counts(specs) => {
1573 if let Ok(Done::Counts(counts)) = done {
1574 for (spec, count) in specs.into_iter().zip(counts) {
1575 self.unseen.insert(spec, count);
1576 }
1577 self.requests.push(Request::MailboxesChanged);
1578 }
1579 }
1580 Pending::Again(again) => match done {
1581 Err(err) => self.error(format!("cannot fetch message: {err:#}")),
1582 Ok(_) => self.run_again(again),
1583 },
1584 Pending::Then { then, .. } => then(self, done),
1585 }
1586 }
1587
1588 fn run_again(&mut self, again: Again) {
1591 match again {
1592 Again::View => self.open_message(),
1593 Again::Copy {
1594 input,
1595 delete,
1596 tagged,
1597 decode,
1598 } => self.copy_message(&input, delete, tagged, decode),
1599 Again::Pipe { command, tagged } => self.pipe_message(&command, tagged),
1600 Again::Print { tagged } => self.print_current(tagged),
1601 Again::Bounce { to, tagged } => self.bounce_current(&to, tagged),
1602 }
1603 }
1604
1605 fn have_bodies(&mut self, paths: &[PathBuf], again: Again) -> bool {
1612 if self.imap.is_none() {
1613 return true;
1614 }
1615 let missing: Vec<PathBuf> = paths
1616 .iter()
1617 .filter(|path| remote::is_partial(path))
1618 .cloned()
1619 .collect();
1620 if missing.is_empty() {
1621 return true;
1622 }
1623 if self.pending.is_some() {
1627 self.deferred.push_back(Deferred::Again(again));
1628 return false;
1629 }
1630 !self.start(Job::FetchBodies(missing), Pending::Again(again))
1631 }
1632
1633 fn target_paths(&self, tagged: bool) -> Vec<PathBuf> {
1636 self.op_targets(tagged)
1637 .into_iter()
1638 .map(|i| self.msgs[i].env.file.path.clone())
1639 .collect()
1640 }
1641
1642 pub fn idle_kick(&self) -> bool {
1645 self.idle.as_ref().is_some_and(|w| w.take_changed())
1646 }
1647
1648 fn check_other_mailboxes(&mut self) {
1651 let mut grew: Vec<String> = Vec::new();
1652 for spec in self.config.mail.mailboxes.clone() {
1653 if spec.starts_with("imap:") || spec == self.title {
1654 continue; }
1656 let dir = expand_tilde(&spec);
1657 if dir == self.dir || !dir.join("new").is_dir() {
1658 continue;
1659 }
1660 let count = maildir::new_count(&dir);
1661 let prev = self.mailbox_new.insert(spec.clone(), count);
1662 if let Some(p) = prev.filter(|&p| count > p) {
1663 self.run_new_mail_command(&spec, count - p);
1664 grew.push(spec);
1665 } else if !self.config.mail.mail_check_recent.unwrap_or(true) {
1666 if count == 0 {
1670 self.mailbox_announced.remove(&spec);
1671 } else if self.mailbox_announced.insert(spec.clone()) {
1672 grew.push(spec);
1673 }
1674 }
1675 }
1676 if !grew.is_empty() && self.notice().is_none() {
1678 self.notify(Notice::NewMail(format!("new mail in {}", grew.join(", "))));
1679 }
1680 }
1681
1682 pub fn maybe_backfill(&mut self) {
1685 if self.backfill.as_ref().is_some_and(|b| !b.done()) {
1686 return;
1687 }
1688 self.backfill = None;
1689 let Some(imap) = &mut self.imap else {
1690 return;
1691 };
1692 if imap.facts.pending_backfill.is_empty() {
1693 return;
1694 }
1695 let uids = imap.take_backfill();
1696 let facts = imap.facts.clone();
1697 let Ok(password) = account_password(&facts.account) else {
1698 return;
1699 };
1700 let count = uids.len();
1701 self.backfill = Some(remote::backfill(
1702 &facts.account,
1703 &facts.mailbox,
1704 &password,
1705 facts.cache,
1706 uids,
1707 ));
1708 self.note(format!(
1709 "loading {count} older message(s) in the background"
1710 ));
1711 }
1712
1713 pub fn error_history(&self) -> Vec<String> {
1717 self.error_history.iter().cloned().collect()
1718 }
1719
1720 pub fn next_unread_mailbox(&self) -> Option<String> {
1725 let boxes = &self.config.mail.mailboxes;
1726 let start = boxes
1727 .iter()
1728 .position(|spec| *spec == self.title || expand_tilde(spec) == self.dir)
1729 .map(|i| i + 1)
1730 .unwrap_or(0);
1731 (0..boxes.len())
1732 .map(|k| &boxes[(start + k) % boxes.len()])
1733 .filter(|spec| **spec != self.title && expand_tilde(spec) != self.dir)
1734 .find(|spec| {
1735 if spec.starts_with("imap:") {
1736 self.unseen.get(*spec).copied().unwrap_or(0) > 0
1737 } else {
1738 maildir::new_count(&expand_tilde(spec)) > 0
1739 }
1740 })
1741 .cloned()
1742 }
1743
1744 pub fn delete_rules(&self) -> DeleteRules {
1747 DeleteRules {
1748 flag_safe: self.config.mail.flag_safe,
1749 untag: self.config.mail.delete_untag.unwrap_or(true),
1750 }
1751 }
1752
1753 pub fn each_tagged(&mut self, what: &str, f: impl Fn(&mut Msg)) {
1754 let tagged: Vec<usize> = (0..self.msgs.len())
1755 .filter(|&i| self.msgs[i].env.tagged)
1756 .collect();
1757 self.push_undo(what, &tagged);
1758 for &i in &tagged {
1759 let m = &mut self.msgs[i];
1760 f(m);
1761 m.dirty = true;
1762 }
1763 self.note(format!("applied to {} tagged message(s)", tagged.len()));
1764 }
1765
1766 pub fn op_targets(&self, tagged: bool) -> Vec<usize> {
1774 if tagged {
1775 return (0..self.msgs.len())
1776 .filter(|&i| self.msgs[i].env.tagged)
1777 .collect();
1778 }
1779 self.visible.get(self.sel).copied().into_iter().collect()
1780 }
1781
1782 pub fn push_undo(&mut self, what: &str, indices: &[usize]) {
1785 if indices.is_empty() {
1786 return;
1787 }
1788 let marks = indices.iter().map(|&i| self.mark(i)).collect();
1789 self.push_undo_step(UndoStep {
1790 what: what.to_string(),
1791 marks,
1792 sel: self.selected_path(),
1793 created: Vec::new(),
1794 note: None,
1795 rewritten: Vec::new(),
1796 });
1797 }
1798
1799 pub fn push_undo_step(&mut self, step: UndoStep) {
1800 self.undo.push(step);
1801 while self.undo.len() > UNDO_MAX_STEPS
1803 || (self.undo.len() > 1
1804 && self.undo.iter().map(|s| s.marks.len()).sum::<usize>() > UNDO_MAX_MARKS)
1805 {
1806 self.undo.remove(0);
1807 }
1808 }
1809
1810 pub fn mark(&self, i: usize) -> MsgMark {
1811 let m = &self.msgs[i];
1812 MsgMark {
1813 path: m.env.file.path.clone(),
1814 flags: m.env.file.flags,
1815 is_new: m.env.file.is_new,
1816 tagged: m.env.tagged,
1817 dirty: m.dirty,
1818 }
1819 }
1820
1821 pub fn undo_last(&mut self) {
1827 let Some(step) = self.undo.pop() else {
1828 self.note("nothing to undo");
1829 return;
1830 };
1831 let mut restored = 0usize;
1832 let mut rewrite_failed = Vec::new();
1833 let mut resort = false;
1834 for (path, bytes) in &step.rewritten {
1835 let at = self.msgs.iter().position(|m| &m.env.file.path == path);
1836 let put_back = maildir::replace_content(path, bytes).and_then(|()| match at {
1837 Some(i) => self.reread(i, bytes.len() as u64),
1838 None => Ok(()),
1839 });
1840 match put_back {
1841 Ok(()) => resort = true,
1842 Err(err) => rewrite_failed.push(format!("{}: {err}", path.display())),
1843 }
1844 }
1845 if resort {
1846 let keep = step.sel.clone();
1847 self.resort(keep);
1848 }
1849 let by_path: HashMap<&Path, usize> = self
1850 .msgs
1851 .iter()
1852 .enumerate()
1853 .map(|(i, m)| (m.env.file.path.as_path(), i))
1854 .collect();
1855 let mut wanted: Vec<(usize, MsgMark)> = Vec::new();
1856 for mark in &step.marks {
1857 if let Some(&i) = by_path.get(mark.path.as_path()) {
1858 wanted.push((i, mark.clone()));
1859 }
1860 }
1861 for (i, mark) in wanted {
1862 let m = &mut self.msgs[i];
1863 m.env.file.flags = mark.flags;
1864 m.env.file.is_new = mark.is_new;
1865 m.env.tagged = mark.tagged;
1866 m.dirty = mark.dirty;
1867 restored += 1;
1868 }
1869 let mut failed = rewrite_failed;
1870 for path in &step.created {
1871 if let Err(err) = std::fs::remove_file(path) {
1872 failed.push(format!("{}: {err}", path.display()));
1873 }
1874 }
1875 if let Some(vi) = step.sel.and_then(|p| {
1876 self.visible
1877 .iter()
1878 .position(|&i| self.msgs[i].env.file.path == p)
1879 }) {
1880 self.sel = vi;
1881 }
1882 let mut note = format!("undone: {} ({restored} message(s))", step.what);
1883 if let Some(extra) = &step.note {
1884 note += &format!("; {extra}");
1885 }
1886 if !failed.is_empty() {
1887 self.error(format!("{note}; could not remove {}", failed.join("; ")));
1888 } else {
1889 self.note(note);
1890 }
1891 }
1892
1893 pub fn message_bytes(&mut self, i: usize) -> Option<Vec<u8>> {
1896 self.settle();
1900 let path = self.msgs.get(i)?.env.file.path.clone();
1901 if remote::is_partial(&path)
1902 && let Some(imap) = &mut self.imap
1903 && let Err(err) = imap.blocking(Job::FetchBodies(vec![path.clone()]))
1904 {
1905 self.error(format!("cannot fetch message: {err:#}"));
1906 return None;
1907 }
1908 match std::fs::read(&path) {
1909 Ok(bytes) => Some(bytes),
1910 Err(err) => {
1911 self.error(format!("cannot read message: {err}"));
1912 None
1913 }
1914 }
1915 }
1916
1917 pub fn full_message_bytes(&mut self) -> Option<Vec<u8>> {
1918 let &i = self.visible.get(self.sel)?;
1919 self.message_bytes(i)
1920 }
1921
1922 pub fn op_bytes(&mut self, tagged: bool) -> Option<Vec<u8>> {
1924 let mut out = Vec::new();
1925 for i in self.op_targets(tagged) {
1926 let bytes = self.message_bytes(i)?;
1927 out.extend_from_slice(&bytes);
1928 if !bytes.ends_with(b"\n") {
1929 out.push(b'\n');
1930 }
1931 }
1932 Some(out)
1933 }
1934
1935 pub fn rebuild_visible(&mut self, keep: Option<PathBuf>) {
1938 let mut visible = Vec::with_capacity(self.msgs.len());
1939 let positions = self.positions();
1940 for (i, pos) in positions.iter().enumerate() {
1941 let limit_ok = match &self.limit {
1942 Some((_, patterns)) => self.env_matches_at(patterns, &self.msgs[i].env, *pos, i),
1943 None => true,
1944 };
1945 if !limit_ok {
1946 continue;
1947 }
1948 if self.limit.is_none()
1949 && self.sort == SortKey::Threads
1950 && self.thread_depth.get(i).copied().unwrap_or(0) > 0
1951 {
1952 let root = self.thread_root.get(i).copied().unwrap_or(i);
1953 if self.collapsed.contains(&self.msgs[root].env.file.path) {
1954 continue;
1955 }
1956 }
1957 visible.push(i);
1958 }
1959 self.visible = visible;
1960 self.sel = keep
1961 .and_then(|p| {
1962 self.visible
1963 .iter()
1964 .position(|&i| self.msgs[i].env.file.path == p)
1965 })
1966 .unwrap_or_else(|| self.visible.len().saturating_sub(1));
1967 }
1968
1969 pub fn apply_sort(&mut self) {
1970 let keep = self.selected_path();
1971 self.resort(keep);
1972 }
1973
1974 fn resort(&mut self, keep: Option<PathBuf>) {
1975 if self.sort == SortKey::Threads {
1976 let order = self
1979 .config
1980 .index
1981 .sort_aux
1982 .as_deref()
1983 .map(thread::ThreadOrder::parse)
1984 .unwrap_or_default();
1985 let items = {
1990 let fallback = (!self.config.index.strict_threads.unwrap_or(false)).then(|| {
1991 thread::SubjectFallback {
1992 reply_re: &self.reply_re,
1993 sort_re: self.config.index.sort_re.unwrap_or(true),
1994 }
1995 });
1996 let envs: Vec<&Envelope> = self.msgs.iter().map(|m| &m.env).collect();
1997 thread::thread_with(&envs, order, fallback.as_ref())
1998 };
1999 let mut old: Vec<Option<Msg>> = self.msgs.drain(..).map(Some).collect();
2000 let mut new_pos = vec![0usize; old.len()];
2001 for (pos, item) in items.iter().enumerate() {
2002 new_pos[item.index] = pos;
2003 }
2004 self.msgs = items
2005 .iter()
2006 .map(|item| {
2007 old[item.index]
2008 .take()
2009 .expect("thread order is a permutation")
2010 })
2011 .collect();
2012 self.thread_depth = items.iter().map(|item| item.depth).collect();
2013 self.thread_root = items.iter().map(|item| new_pos[item.root]).collect();
2014 self.thread_pseudo = items.iter().map(|item| item.pseudo).collect();
2015 self.sort_rev = false;
2016 self.index_threads();
2017 } else {
2018 let (sort, rev) = (self.sort, self.sort_rev);
2019 self.msgs.sort_by(|a, b| {
2020 let ord = match sort {
2021 SortKey::Date => a.env.date.cmp(&b.env.date),
2022 SortKey::From => a.env.from.to_lowercase().cmp(&b.env.from.to_lowercase()),
2023 SortKey::Subject => {
2024 subject_key(&a.env.subject).cmp(&subject_key(&b.env.subject))
2025 }
2026 SortKey::Size => a.env.file.size.cmp(&b.env.file.size),
2027 SortKey::Label => {
2028 let key = |e: &Envelope| {
2030 let l = e.label.clone().unwrap_or_default().to_lowercase();
2031 (l.is_empty(), l)
2032 };
2033 key(&a.env).cmp(&key(&b.env))
2034 }
2035 SortKey::To => {
2036 let first_to =
2037 |e: &Envelope| e.to.first().cloned().unwrap_or_default().to_lowercase();
2038 first_to(&a.env).cmp(&first_to(&b.env))
2039 }
2040 SortKey::Unsorted => a
2041 .env
2042 .file
2043 .path
2044 .file_name()
2045 .cmp(&b.env.file.path.file_name()),
2046 SortKey::Threads => unreachable!(),
2047 };
2048 if rev { ord.reverse() } else { ord }
2049 });
2050 self.thread_depth = vec![0; self.msgs.len()];
2051 self.thread_root = (0..self.msgs.len()).collect();
2052 self.thread_pseudo = vec![false; self.msgs.len()];
2053 self.index_threads();
2054 }
2055 self.rebuild_visible(keep);
2056 }
2057
2058 fn index_threads(&mut self) {
2061 let n = self.msgs.len();
2062 let mut parent = vec![None; n];
2063 let mut children = vec![Vec::new(); n];
2064 let mut members: Vec<Vec<usize>> = vec![Vec::new(); n];
2065 let mut last_at_depth: Vec<usize> = Vec::new();
2066 #[allow(clippy::needless_range_loop)]
2067 for i in 0..n {
2068 let depth = self.thread_depth.get(i).copied().unwrap_or(0);
2069 last_at_depth.truncate(depth);
2070 if let Some(&p) = last_at_depth.last() {
2071 parent[i] = Some(p);
2072 children[p].push(i);
2073 }
2074 last_at_depth.push(i);
2075 let root = self.thread_root.get(i).copied().unwrap_or(i);
2076 members[root].push(i);
2077 }
2078 self.thread_parent = parent;
2079 self.thread_children = children;
2080 self.thread_members = members;
2081 }
2082
2083 pub fn toggle_collapse(&mut self, all: bool) {
2084 if self.sort != SortKey::Threads {
2085 self.error("folding needs thread sort (o t)");
2086 return;
2087 }
2088 let mut keep;
2089 if all {
2090 keep = self.selected_path();
2091 if self.collapsed.is_empty() {
2092 let fold_unread = self.config.index.collapse_unread.unwrap_or(true);
2095 self.collapsed = (0..self.msgs.len())
2096 .filter(|&i| self.thread_depth.get(i) == Some(&0))
2097 .filter(|&i| fold_unread || !self.thread_has_unread(i))
2098 .map(|i| self.msgs[i].env.file.path.clone())
2099 .collect();
2100 } else {
2101 self.collapsed.clear();
2102 }
2103 } else {
2104 let Some(&mi) = self.visible.get(self.sel) else {
2105 return;
2106 };
2107 let root = self.thread_root.get(mi).copied().unwrap_or(mi);
2108 let path = self.msgs[root].env.file.path.clone();
2109 let unfolding = self.collapsed.remove(&path);
2110 if !unfolding {
2111 self.collapsed.insert(path.clone());
2112 }
2113 keep = Some(path);
2114 if unfolding && self.config.index.uncollapse_jump {
2116 keep = self.first_unread_in_thread(root).or(keep);
2117 }
2118 }
2119 self.rebuild_visible(keep);
2120 }
2121
2122 pub fn step_message(&self, forward: bool, skip_deleted: bool) -> Option<usize> {
2125 let mut pos = self.sel;
2126 loop {
2127 pos = if forward {
2128 pos + 1
2129 } else {
2130 pos.checked_sub(1)?
2131 };
2132 let &i = self.visible.get(pos)?;
2133 if !skip_deleted || !self.msgs[i].env.file.flags.deleted {
2134 return Some(pos);
2135 }
2136 }
2137 }
2138
2139 fn thread_targets(&mut self, sub: bool) -> Option<Vec<usize>> {
2147 if self.sort != SortKey::Threads {
2148 self.error("thread operations need thread sort (o t)");
2149 return None;
2150 }
2151 let &mi = self.visible.get(self.sel)?;
2152 if !sub {
2153 let root = self.thread_root.get(mi).copied().unwrap_or(mi);
2154 return Some(
2155 (0..self.msgs.len())
2156 .filter(|&i| self.thread_root.get(i).copied().unwrap_or(i) == root)
2157 .collect(),
2158 );
2159 }
2160 let depth = self.thread_depth.get(mi).copied().unwrap_or(0);
2163 let mut out = vec![mi];
2164 for i in (mi + 1)..self.msgs.len() {
2165 if self.thread_depth.get(i).copied().unwrap_or(0) <= depth {
2166 break;
2167 }
2168 out.push(i);
2169 }
2170 Some(out)
2171 }
2172
2173 pub fn thread_mark(&mut self, sub: bool, op: ThreadOp) {
2177 if op != ThreadOp::Tag && self.deny_readonly() {
2178 return;
2179 }
2180 let Some(&mi) = self.visible.get(self.sel) else {
2181 return;
2182 };
2183 let Some(targets) = self.thread_targets(sub) else {
2184 return;
2185 };
2186 let scope = if sub { "subthread" } else { "thread" };
2187 let (what, verb) = match op {
2188 ThreadOp::Delete => (format!("delete {scope}"), "deleted"),
2189 ThreadOp::Undelete => (format!("undelete {scope}"), "undeleted"),
2190 ThreadOp::Read => (format!("read {scope}"), "marked read"),
2191 ThreadOp::Tag if self.msgs[mi].env.tagged => (format!("untag {scope}"), "untagged"),
2194 ThreadOp::Tag => (format!("tag {scope}"), "tagged"),
2195 };
2196 self.push_undo(&what, &targets);
2197 let want_tag = !self.msgs[mi].env.tagged;
2198 let rules = self.delete_rules();
2199 for &i in &targets {
2200 match op {
2201 ThreadOp::Delete => {
2202 if rules.mark(&mut self.msgs[i]) {
2203 self.msgs[i].dirty = true;
2204 }
2205 }
2206 ThreadOp::Undelete => {
2207 self.msgs[i].env.file.flags.deleted = false;
2208 self.msgs[i].dirty = true;
2209 }
2210 ThreadOp::Tag => self.msgs[i].env.tagged = want_tag,
2211 ThreadOp::Read => {
2212 let m = &mut self.msgs[i];
2213 if !m.env.file.flags.seen || m.env.file.is_new {
2214 m.env.file.flags.seen = true;
2215 m.env.file.is_new = false;
2216 m.dirty = true;
2217 }
2218 }
2219 }
2220 }
2221 self.note(format!("{} {verb}", targets.len()));
2222 if op == ThreadOp::Delete
2225 && let Some(pos) = self.step_message(true, true)
2226 {
2227 self.sel = pos;
2228 }
2229 }
2230
2231 pub fn jump_parent(&mut self, root: bool) {
2235 if self.sort != SortKey::Threads {
2236 self.error("thread operations need thread sort (o t)");
2237 return;
2238 }
2239 let Some(&mi) = self.visible.get(self.sel) else {
2240 return;
2241 };
2242 let target = if root {
2243 self.thread_root.get(mi).copied().filter(|&r| r != mi)
2244 } else {
2245 self.thread_parent.get(mi).copied().flatten()
2246 };
2247 let Some(target) = target else {
2248 self.error(if root {
2249 "already the thread root"
2250 } else {
2251 "no parent message"
2252 });
2253 return;
2254 };
2255 let tr = self.thread_root.get(target).copied().unwrap_or(target);
2256 let at = self
2257 .visible
2258 .iter()
2259 .position(|&i| i == target)
2260 .or_else(|| self.visible.iter().position(|&i| i == tr));
2261 match at {
2262 Some(vi) => self.select(vi),
2263 None => self.error("the parent is not in the current limit"),
2264 }
2265 }
2266
2267 fn can_rewrite(&mut self) -> bool {
2272 if self.deny_readonly() {
2273 return false;
2274 }
2275 if self.sort != SortKey::Threads {
2276 self.error("thread operations need thread sort (o t)");
2277 return false;
2278 }
2279 if self.imap.is_some() || self.mbox.is_some() {
2280 self.error("rewriting a message's threading is for local maildirs only");
2281 return false;
2282 }
2283 true
2284 }
2285
2286 fn rewrite_thread(
2292 &mut self,
2293 i: usize,
2294 in_reply_to: Option<&str>,
2295 references: &[String],
2296 broken: bool,
2297 ) -> Result<Vec<u8>> {
2298 let path = self.msgs[i].env.file.path.clone();
2299 let old = std::fs::read(&path).with_context(|| format!("reading {}", path.display()))?;
2300 let new = message::with_thread_headers(&old, in_reply_to, references, broken);
2301 maildir::replace_content(&path, &new)?;
2302 self.reread(i, new.len() as u64)?;
2303 Ok(old)
2304 }
2305
2306 fn reread(&mut self, i: usize, size: u64) -> Result<()> {
2309 let mut file = self.msgs[i].env.file.clone();
2310 file.size = size;
2311 let tagged = self.msgs[i].env.tagged;
2312 let mut env = message::envelope(file)?;
2313 env.tagged = tagged;
2314 self.msgs[i].env = env;
2315 Ok(())
2316 }
2317
2318 pub fn break_thread(&mut self) {
2323 let Some(&mi) = self.visible.get(self.sel) else {
2324 return;
2325 };
2326 if !self.can_rewrite() {
2327 return;
2328 }
2329 if self.msgs[mi].env.references.is_empty() && !self.subject_threaded(mi) {
2333 self.note("already a thread of its own");
2334 return;
2335 }
2336 let path = self.msgs[mi].env.file.path.clone();
2337 let mut step = UndoStep {
2338 what: "break thread".into(),
2339 marks: vec![self.mark(mi)],
2340 sel: Some(path.clone()),
2341 created: Vec::new(),
2342 note: None,
2343 rewritten: Vec::new(),
2344 };
2345 match self.rewrite_thread(mi, None, &[], true) {
2346 Ok(old) => {
2347 step.rewritten.push((path.clone(), old));
2348 self.push_undo_step(step);
2349 self.resort(Some(path));
2350 self.note("thread broken");
2351 }
2352 Err(err) => self.error(format!("break thread: {err:#}")),
2353 }
2354 }
2355
2356 pub fn link_threads(&mut self) {
2361 let Some(&mi) = self.visible.get(self.sel) else {
2362 return;
2363 };
2364 if !self.can_rewrite() {
2365 return;
2366 }
2367 let Some(parent_id) = self.msgs[mi].env.msg_id.clone() else {
2368 self.error("no Message-ID to link to");
2369 return;
2370 };
2371 let kids: Vec<usize> = (0..self.msgs.len())
2372 .filter(|&i| i != mi && self.msgs[i].env.tagged)
2373 .collect();
2374 if kids.is_empty() {
2375 self.error("first tag the message(s) to link");
2376 return;
2377 }
2378 let keep = self.msgs[mi].env.file.path.clone();
2379 let mut step = UndoStep {
2380 what: "link threads".into(),
2381 marks: kids.iter().map(|&i| self.mark(i)).collect(),
2382 sel: Some(keep.clone()),
2383 created: Vec::new(),
2384 note: None,
2385 rewritten: Vec::new(),
2386 };
2387 let mut linked = 0usize;
2388 for &i in &kids {
2389 let path = self.msgs[i].env.file.path.clone();
2390 match self.rewrite_thread(i, Some(&parent_id), &[], false) {
2391 Ok(old) => {
2392 step.rewritten.push((path, old));
2393 self.msgs[i].env.tagged = false;
2394 linked += 1;
2395 }
2396 Err(err) => self.error(format!("link threads: {err:#}")),
2397 }
2398 }
2399 if linked > 0 {
2400 self.push_undo_step(step);
2401 self.resort(Some(keep));
2402 self.note(format!("{linked} linked"));
2403 }
2404 }
2405
2406 pub fn edit_label(&mut self, value: &str, tagged: bool) {
2411 if !self.can_rewrite_here() {
2412 return;
2413 }
2414 let targets = self.op_targets(tagged);
2415 if targets.is_empty() {
2416 return;
2417 }
2418 let value = value.trim();
2419 let new = (!value.is_empty()).then(|| value.to_string());
2420 let keep = self.selected_path();
2421 let mut step = UndoStep {
2422 what: "edit label".into(),
2423 marks: targets.iter().map(|&i| self.mark(i)).collect(),
2424 sel: keep.clone(),
2425 created: Vec::new(),
2426 note: None,
2427 rewritten: Vec::new(),
2428 };
2429 let mut done = 0usize;
2430 for &i in &targets {
2431 let path = self.msgs[i].env.file.path.clone();
2432 let old = match std::fs::read(&path) {
2433 Ok(b) => b,
2434 Err(err) => {
2435 self.error(format!("edit label: {err}"));
2436 continue;
2437 }
2438 };
2439 let bytes = message::with_header(&old, "X-Label", new.as_deref());
2440 if let Err(err) = maildir::replace_content(&path, &bytes) {
2441 self.error(format!("edit label: {err:#}"));
2442 continue;
2443 }
2444 step.rewritten.push((path, old));
2445 let _ = self.reread(i, bytes.len() as u64);
2446 done += 1;
2447 }
2448 if done > 0 {
2449 self.push_undo_step(step);
2450 self.resort(keep);
2451 self.note(match new {
2452 Some(_) => format!("labelled {done} message(s)"),
2453 None => format!("label cleared on {done} message(s)"),
2454 });
2455 }
2456 }
2457
2458 fn can_rewrite_here(&mut self) -> bool {
2461 if self.deny_readonly() {
2462 return false;
2463 }
2464 if self.imap.is_some() || self.mbox.is_some() {
2465 self.error("editing a label is for local maildirs only");
2466 return false;
2467 }
2468 true
2469 }
2470
2471 pub fn subject_threaded(&self, mi: usize) -> bool {
2475 self.thread_pseudo.get(mi).copied().unwrap_or(false)
2476 }
2477
2478 pub fn subject_hidden(&self, mi: usize) -> bool {
2483 if !self.config.index.hide_thread_subject.unwrap_or(false) {
2484 return false;
2485 }
2486 let Some(parent) = self.thread_parent.get(mi).copied().flatten() else {
2487 return false;
2488 };
2489 match (self.msgs.get(mi), self.msgs.get(parent)) {
2490 (Some(m), Some(p)) => subject_key(&m.env.subject) == subject_key(&p.env.subject),
2491 _ => false,
2492 }
2493 }
2494
2495 pub fn toggle_write(&mut self) {
2499 if self.read_only_session {
2500 self.error("mailbox is read-only for the whole session (-R)");
2501 return;
2502 }
2503 self.read_only = !self.read_only;
2504 if self.read_only {
2505 self.note("mailbox marked read-only");
2506 } else {
2507 self.note("mailbox marked writable");
2508 }
2509 }
2510
2511 pub fn show_limit(&mut self) {
2514 match &self.limit {
2515 Some((raw, _)) => self.note(format!("limit: {raw}")),
2516 None => self.note("no limit pattern (all messages shown)"),
2517 }
2518 }
2519
2520 pub fn reply_subject(&self, orig: &str) -> String {
2522 compose::reply_subject(orig, &self.reply_re)
2523 }
2524
2525 pub fn jump_thread(&mut self, forward: bool) {
2528 if self.sort != SortKey::Threads {
2529 self.error("thread operations need thread sort (o t)");
2530 return;
2531 }
2532 for (vi, wrapped) in wrap_order(self.visible.len(), self.sel, forward) {
2533 let mi = self.visible[vi];
2534 if self.thread_depth.get(mi).copied().unwrap_or(0) == 0 {
2535 if wrapped {
2536 self.note("wrapped around");
2537 }
2538 self.select(vi);
2539 return;
2540 }
2541 }
2542 self.error("no other thread");
2543 }
2544
2545 pub fn jump_new(&mut self, forward: bool) {
2546 let n = self.visible.len();
2547 if n == 0 {
2548 return;
2549 }
2550 for (vi, wrapped) in wrap_order(n, self.sel, forward) {
2551 let m = &self.msgs[self.visible[vi]];
2552 if m.env.file.is_new || !m.env.file.flags.seen {
2553 if wrapped {
2554 self.note("search wrapped");
2555 }
2556 self.select(vi);
2557 return;
2558 }
2559 }
2560 self.error("no new or unread messages");
2561 }
2562
2563 pub fn compile_search(&self, input: &str) -> Result<Vec<Pattern>, String> {
2571 pattern::parse(&self.expand_simple(input))
2572 }
2573
2574 fn expand_simple(&self, input: &str) -> String {
2575 let word = input.trim();
2576 if word.contains('~') || word.split_whitespace().count() != 1 {
2577 return input.to_string();
2578 }
2579 let template = self
2580 .config
2581 .mail
2582 .simple_search
2583 .as_deref()
2584 .unwrap_or("~f %s | ~s %s");
2585 template.replace("%s", word)
2586 }
2587
2588 pub fn apply_pattern(&mut self, input: &str, op: PatternOp) {
2589 let flag_safe = self.config.mail.flag_safe;
2590 let verb = op.verb();
2591 if input.is_empty() {
2592 return;
2593 }
2594 let patterns = match self.compile_search(input) {
2595 Ok(p) => p,
2596 Err(err) => {
2597 self.error(format!("bad pattern: {err}"));
2598 return;
2599 }
2600 };
2601 let redo_input = input.to_string();
2602 if !self.body_terms_ready(
2603 &patterns,
2604 Box::new(move |session| session.apply_pattern(&redo_input, op)),
2605 ) {
2606 return;
2607 }
2608 let positions = self.positions();
2609 let mut hits = Vec::new();
2610 for (i, pos) in positions.iter().enumerate() {
2611 let in_limit = match &self.limit {
2612 Some((_, l)) => self.env_matches_at(l, &self.msgs[i].env, *pos, i),
2613 None => true,
2614 };
2615 if in_limit && self.env_matches_at(&patterns, &self.msgs[i].env, *pos, i) {
2616 hits.push(i);
2617 }
2618 }
2619 self.push_undo(&format!("{verb} by pattern"), &hits);
2620 for &i in &hits {
2621 op.apply(&mut self.msgs[i], flag_safe);
2622 }
2623 self.note(format!("{} {verb}", hits.len()));
2624 }
2625
2626 pub fn search_next(&mut self) {
2627 let Some(patterns) = self.last_search.clone() else {
2628 self.error("no search pattern (use /)");
2629 return;
2630 };
2631 if self.visible.is_empty() {
2632 return;
2633 }
2634 let wrap = self.config.mail.wrap_search.unwrap_or(true);
2637 let positions = self.positions();
2638 for (vi, wrapped) in wrap_order(self.visible.len(), self.sel, !self.search_rev) {
2639 if wrapped && !wrap {
2640 break;
2641 }
2642 let mi = self.visible[vi];
2643 if self.env_matches_at(&patterns, &self.msgs[mi].env, positions[mi], mi) {
2644 if wrapped {
2645 self.note("search wrapped");
2646 }
2647 self.sel = vi;
2648 return;
2649 }
2650 }
2651 self.error("not found");
2652 }
2653
2654 fn trash_deleted(&mut self, trash: &str) -> Result<()> {
2658 self.settle();
2662 let deleted: Vec<PathBuf> = self
2664 .msgs
2665 .iter()
2666 .filter(|m| m.env.file.flags.deleted && !m.purge)
2667 .map(|m| m.env.file.path.clone())
2668 .collect();
2669 match (remote::parse_spec(trash), &mut self.imap) {
2670 (Some((account, folder)), Some(imap)) if imap.facts.account.name == account => imap
2671 .blocking(Job::CopyToFolder {
2672 paths: deleted,
2673 mailbox: folder.to_string(),
2674 })
2675 .map(|_| ()),
2676 (Some(_), _) => anyhow::bail!("trash must be a folder of the open account"),
2677 (None, Some(_)) => {
2678 anyhow::bail!("an IMAP mailbox needs an imap:account/folder trash")
2679 }
2680 (None, None) => {
2681 let dir = expand_tilde(trash);
2682 maildir::create(&dir)?;
2683 for m in self
2684 .msgs
2685 .iter()
2686 .filter(|m| m.env.file.flags.deleted && !m.purge)
2687 {
2688 let bytes = std::fs::read(&m.env.file.path)?;
2689 let mut flags = m.env.file.flags;
2690 flags.deleted = false;
2691 maildir::deliver(&dir, &bytes, flags)?;
2692 }
2693 Ok(())
2694 }
2695 }
2696 }
2697
2698 pub fn sync(&mut self, purge: bool) {
2701 if self.deny_readonly() {
2702 return;
2703 }
2704 if purge
2707 && self.deleted_count() > 0
2708 && let Some(trash) = self.config.mail.trash.clone()
2709 && trash != self.title
2710 && expand_tilde(&trash) != self.dir
2711 && let Err(err) = self.trash_deleted(&trash)
2712 {
2713 self.error(format!("trash failed: {err:#}; nothing purged"));
2714 return;
2715 }
2716 if self.imap.is_some() {
2717 let mut deletes: Vec<PathBuf> = Vec::new();
2718 let mut flags: Vec<(PathBuf, maildir::Flags)> = Vec::new();
2719 for m in &self.msgs {
2720 if m.env.file.flags.deleted {
2721 if purge {
2722 deletes.push(m.env.file.path.clone());
2723 }
2724 } else if m.dirty {
2725 flags.push((m.env.file.path.clone(), m.env.file.flags));
2726 }
2727 }
2728 let sent = Sent {
2732 flags: flags.iter().cloned().collect(),
2733 deletes: deletes.iter().cloned().collect(),
2734 };
2735 if self.start(Job::Sync { flags, deletes }, Pending::Sync { purge, sent }) {
2736 return;
2737 }
2738 }
2739 self.finish_sync(purge, None);
2740 }
2741
2742 fn finish_sync(&mut self, purge: bool, sent: Option<&Sent>) {
2750 if let Some(mbox) = &mut self.mbox {
2751 let mut state: HashMap<String, Option<(maildir::Flags, bool)>> = HashMap::new();
2754 for m in &self.msgs {
2755 let Some(id) = mbox::id_of(&m.env.file.path) else {
2756 continue;
2757 };
2758 if m.env.file.flags.deleted && purge {
2759 state.insert(id, None);
2760 } else if m.pending() {
2761 let is_new = m.env.file.is_new && !m.dirty;
2762 state.insert(id, Some((m.env.file.flags, is_new)));
2763 }
2764 }
2765 if let Err(err) = mbox.write_back(&state) {
2766 self.error(format!("sync failed: {err:#}"));
2768 return;
2769 }
2770 }
2771 let keep = self.selected_path();
2772 let mut removed = 0usize;
2773 let mut saved = 0usize;
2774 let mut errors: Vec<String> = Vec::new();
2775 let trash = self.config.mail.maildir_trash && self.imap.is_none() && self.mbox.is_none();
2779 self.msgs.retain_mut(|m| {
2780 let path = &m.env.file.path;
2781 if let Some(sent) = sent {
2782 if sent.deletes.contains(path) {
2783 match maildir::remove(&m.env.file) {
2785 Ok(()) => {
2786 removed += 1;
2787 return false;
2788 }
2789 Err(err) => {
2790 errors.push(err.to_string());
2791 return true;
2792 }
2793 }
2794 }
2795 if m.env.file.flags.deleted {
2796 return true; }
2798 if m.dirty && sent.flags.get(path) != Some(&m.env.file.flags) {
2799 return true; }
2801 }
2802 if m.env.file.flags.deleted {
2803 if !purge {
2804 return true; }
2806 if trash {
2807 if m.dirty {
2808 match maildir::store_flags(&m.env.file) {
2809 Ok(path) => {
2810 m.env.file.path = path;
2811 m.env.file.is_new = false;
2812 m.dirty = false;
2813 saved += 1;
2814 }
2815 Err(err) => errors.push(err.to_string()),
2816 }
2817 }
2818 return true;
2819 }
2820 match maildir::remove(&m.env.file) {
2821 Ok(()) => {
2822 removed += 1;
2823 false
2824 }
2825 Err(err) => {
2826 errors.push(err.to_string());
2827 true
2828 }
2829 }
2830 } else {
2831 if m.dirty {
2832 match maildir::store_flags(&m.env.file) {
2833 Ok(path) => {
2834 m.env.file.path = path;
2835 m.env.file.is_new = false;
2836 m.dirty = false;
2837 saved += 1;
2838 }
2839 Err(err) => errors.push(err.to_string()),
2840 }
2841 }
2842 true
2843 }
2844 });
2845 self.dir_mtimes = dir_mtimes(&self.dir);
2846 self.undo.clear();
2849 self.resort(keep);
2850 match errors.is_empty() {
2851 true => self.notify(Notice::Synced {
2852 deleted: removed,
2853 updated: saved,
2854 }),
2855 false => self.note(format!("sync errors: {}", errors.join("; "))),
2856 }
2857 }
2858
2859 pub fn create_folder(&mut self, spec: &str) -> Result<String, String> {
2870 let id = self.manage(
2871 spec,
2872 |_account, folder| Manage::Create(folder),
2873 |path| {
2874 maildir::create(&path).map_err(|e| format!("{e:#}"))?;
2875 Ok(path.display().to_string())
2876 },
2877 )?;
2878 Ok(format!("created {id}"))
2879 }
2880
2881 pub fn delete_folder(&mut self, spec: &str) -> Result<String, String> {
2882 let id = self.manage(
2883 spec,
2884 |_account, folder| Manage::Delete(folder),
2885 |path| {
2886 std::fs::remove_dir_all(&path).map_err(|e| format!("{e}"))?;
2889 Ok(path.display().to_string())
2890 },
2891 )?;
2892 Ok(format!("deleted {id}"))
2893 }
2894
2895 pub fn set_subscribed(&mut self, spec: &str, on: bool) -> Result<String, String> {
2896 let verb = if on {
2897 "subscribed to"
2898 } else {
2899 "unsubscribed from"
2900 };
2901 self.manage(
2902 spec,
2903 move |_account, folder| Manage::Subscribe(folder, on),
2904 |_path| Err("subscription is an IMAP notion".into()),
2905 )
2906 .map(|shown| format!("{verb} {shown}"))
2907 }
2908
2909 pub fn rename_folder(&mut self, spec: &str, new: &str) -> Result<String, String> {
2912 if new.trim().is_empty() {
2913 return Err("no new name given".into());
2914 }
2915 match remote::parse_spec(spec) {
2916 Some((account, from)) => {
2917 let to = new.trim().to_string();
2918 self.on_account(account, Manage::Rename(from.to_string(), to.clone()))?;
2919 Ok(format!("renamed to {to}"))
2920 }
2921 None => {
2922 let from = expand_tilde(spec);
2923 let to = expand_tilde(new);
2924 std::fs::rename(&from, &to).map_err(|e| format!("{e}"))?;
2925 Ok(format!("renamed to {}", to.display()))
2926 }
2927 }
2928 }
2929
2930 fn manage(
2934 &mut self,
2935 spec: &str,
2936 make: impl FnOnce(&str, String) -> Manage,
2937 local: impl FnOnce(std::path::PathBuf) -> Result<String, String>,
2938 ) -> Result<String, String> {
2939 match remote::parse_spec(spec) {
2940 Some((account, folder)) => {
2941 let action = make(account, folder.to_string());
2942 self.on_account(account, action)?;
2943 Ok(format!("imap:{account}/{folder}"))
2944 }
2945 None => local(expand_tilde(spec)),
2946 }
2947 }
2948
2949 fn on_account(&mut self, account: &str, action: Manage) -> Result<(), String> {
2952 self.settle();
2956 self.server_folders = None;
2959 match &mut self.imap {
2960 Some(imap) if imap.facts.account.name == account => imap
2961 .blocking(Job::Manage(action))
2962 .map(drop)
2963 .map_err(|e| format!("{e:#}")),
2964 Some(_) | None => Err("that mailbox is not on the open account".into()),
2965 }
2966 }
2967
2968 pub fn folder_candidates(&mut self) -> Vec<(String, usize)> {
2976 let mut dirs: Vec<(String, usize)> = self
2979 .config
2980 .mail
2981 .mailboxes
2982 .iter()
2983 .filter(|m| m.starts_with("imap:") || expand_tilde(m).join("cur").is_dir())
2984 .map(|m| {
2985 let count = if m.starts_with("imap:") {
2986 0
2987 } else {
2988 maildir::new_count(&expand_tilde(m))
2989 };
2990 (m.clone(), count)
2991 })
2992 .collect();
2993 match &self.imap {
2994 Some(imap) => {
2995 let account = imap.facts.account.name.clone();
2996 match &self.server_folders {
2997 Some(folders) => dirs.extend(
2998 folders
2999 .iter()
3000 .map(|(f, unseen)| (format!("imap:{account}/{f}"), *unseen)),
3001 ),
3002 None => self.refresh_folders(),
3003 }
3004 }
3005 None => dirs.extend(
3006 maildir::discover(&self.dir)
3007 .iter()
3008 .map(|p| (p.display().to_string(), maildir::new_count(p))),
3009 ),
3010 }
3011 let mut seen: HashMap<String, usize> = HashMap::new();
3014 let mut unique: Vec<(String, usize)> = Vec::new();
3015 for (name, count) in dirs {
3016 match seen.get(&name) {
3017 Some(&at) => unique[at].1 = unique[at].1.max(count),
3018 None => {
3019 seen.insert(name.clone(), unique.len());
3020 unique.push((name, count));
3021 }
3022 }
3023 }
3024 let mut dirs = unique;
3025 sort_browser(&mut dirs, self.config.ui.sort_browser.as_deref());
3026 dirs.dedup_by(|a, b| {
3029 if a.0 == b.0 {
3030 b.1 = b.1.max(a.1);
3031 true
3032 } else {
3033 false
3034 }
3035 });
3036 dirs
3037 }
3038
3039 pub fn refresh_folders(&mut self) {
3043 if self.imap.is_none() || self.listing {
3044 return;
3045 }
3046 self.listing = true;
3047 self.send_then(
3048 Job::Folders,
3049 false,
3050 Box::new(|session, done| {
3051 session.listing = false;
3052 match done {
3053 Ok(Done::Folders(folders)) => {
3054 session.server_folders = Some(folders);
3055 session.requests.push(Request::FoldersChanged);
3056 }
3057 Ok(_) => {}
3058 Err(err) => session.error(format!("imap: {err:#}")),
3059 }
3060 }),
3061 );
3062 }
3063
3064 pub fn unseen_count(&self, spec: &str) -> usize {
3073 match remote::parse_spec(spec) {
3074 Some(_) => self.unseen.get(spec).copied().unwrap_or(0),
3075 None => maildir::new_count(&expand_tilde(spec)),
3076 }
3077 }
3078
3079 fn refresh_unseen(&mut self) -> bool {
3082 let Some(imap) = &self.imap else {
3083 return false;
3084 };
3085 let account = imap.facts.account.name.clone();
3086 let wanted: Vec<(String, String)> = self
3087 .config
3088 .mail
3089 .mailboxes
3090 .iter()
3091 .filter_map(|spec| match remote::parse_spec(spec) {
3092 Some((a, folder)) if a == account => Some((spec.clone(), folder.to_string())),
3093 _ => None,
3094 })
3095 .collect();
3096 if wanted.is_empty() {
3097 return false;
3098 }
3099 let (specs, folders): (Vec<String>, Vec<String>) = wanted.into_iter().unzip();
3100 self.start(Job::Unseen(folders), Pending::Counts(specs))
3101 }
3102
3103 pub fn store_edited(&mut self, path: &Path, edited: &[u8]) {
3108 match &self.imap {
3112 Some(imap) => {
3113 let Some(flags) = self
3114 .visible
3115 .get(self.sel)
3116 .map(|&i| self.msgs[i].env.file.flags)
3117 else {
3118 return;
3119 };
3120 let mailbox = imap.facts.mailbox.clone();
3121 let original = path.to_path_buf();
3122 self.send_then(
3123 Job::Append {
3124 mailbox: Some(mailbox),
3125 flags,
3126 body: edited.to_vec(),
3127 },
3128 true,
3129 Box::new(move |session, done| {
3130 if let Err(err) = done {
3131 session.error(format!("cannot store the edited copy: {err:#}"));
3132 return;
3133 }
3134 let rules = session.delete_rules();
3135 if let Some(m) = session
3136 .msgs
3137 .iter_mut()
3138 .find(|m| m.env.file.path == original)
3139 && rules.mark(m)
3140 {
3141 m.dirty = true;
3142 }
3143 session.check_new_mail();
3144 session.note("edited copy appended; original marked deleted ($ purges)");
3145 }),
3146 );
3147 }
3148 None => {
3149 if let Err(err) = maildir::replace_content(path, edited) {
3150 self.error(format!("cannot write message: {err:#}"));
3151 return;
3152 }
3153 self.rescan();
3154 self.note("message edited");
3155 }
3156 }
3157 }
3158
3159 pub fn mark_all_read(&mut self) {
3166 if self.deny_readonly() {
3167 return;
3168 }
3169 let targets: Vec<usize> = (0..self.msgs.len())
3170 .filter(|&i| {
3171 let f = &self.msgs[i].env.file;
3172 !f.flags.seen || f.is_new
3173 })
3174 .collect();
3175 if targets.is_empty() {
3176 self.note("no unread messages");
3177 return;
3178 }
3179 self.push_undo("mark all read", &targets);
3180 for &i in &targets {
3181 let m = &mut self.msgs[i];
3182 m.env.file.flags.seen = true;
3183 m.env.file.is_new = false;
3184 m.dirty = true;
3185 }
3186 self.note(format!("{} marked read", targets.len()));
3187 }
3188
3189 pub fn tag_span(&mut self, a: usize, b: usize) {
3193 if self.visible.is_empty() {
3194 return;
3195 }
3196 let last = self.visible.len() - 1;
3197 let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
3198 let (lo, hi) = (lo.min(last), hi.min(last));
3199 let targets: Vec<usize> = self.visible[lo..=hi].to_vec();
3200 self.push_undo("tag", &targets);
3201 for &i in &targets {
3202 self.msgs[i].env.tagged = true;
3203 }
3204 self.note(format!("{} tagged", targets.len()));
3205 }
3206
3207 pub fn mark_old_unread(&mut self) {
3208 if self.read_only || !self.config.mail.mark_old.unwrap_or(true) {
3209 return;
3210 }
3211 for m in &mut self.msgs {
3212 if m.env.file.is_new
3213 && !m.env.file.flags.seen
3214 && !m.env.file.flags.deleted
3215 && let Ok(path) = maildir::store_flags(&m.env.file)
3216 {
3217 m.env.file.path = path;
3218 m.env.file.is_new = false;
3219 }
3220 }
3221 }
3222
3223 pub fn ready_to_leave(&mut self) -> bool {
3227 if self.deleted_count() > 0 {
3228 self.error("deleted messages pending; sync with $ or undelete first");
3229 return false;
3230 }
3231 if self.pending_count() > 0 {
3232 self.sync(false);
3233 if self.sync_queued() {
3239 return true;
3240 }
3241 if self.pending_count() > 0 {
3242 return false; }
3244 }
3245 true
3246 }
3247
3248 fn sync_queued(&self) -> bool {
3250 matches!(self.pending, Some(Pending::Sync { .. }))
3251 || self
3252 .deferred
3253 .iter()
3254 .any(|d| matches!(d, Deferred::Job(_, Pending::Sync { .. })))
3255 }
3256
3257 pub fn smtp_account(&self) -> Option<Account> {
3261 if std::env::var("RMUT_SENDMAIL").is_ok() || self.config.mail.sendmail.is_some() {
3262 return None;
3263 }
3264 if let Some(imap) = &self.imap
3265 && imap.facts.account.smtp_host.is_some()
3266 {
3267 return Some(imap.facts.account.clone());
3268 }
3269 self.config
3270 .accounts
3271 .iter()
3272 .find(|a| a.smtp_host.is_some())
3273 .cloned()
3274 }
3275
3276 pub fn copy_message(&mut self, input: &str, delete: bool, tagged: bool, decode: bool) {
3280 if input.is_empty() {
3281 self.error("no mailbox given");
3282 return;
3283 }
3284 let targets = self.op_targets(tagged);
3285 if targets.is_empty() {
3286 return;
3287 }
3288 let paths = self.target_paths(tagged);
3290 if !self.have_bodies(
3291 &paths,
3292 Again::Copy {
3293 input: input.to_string(),
3294 delete,
3295 tagged,
3296 decode,
3297 },
3298 ) {
3299 return;
3300 }
3301 if let Some((account, folder)) = remote::parse_spec(input)
3304 && self
3305 .imap
3306 .as_ref()
3307 .is_some_and(|imap| imap.facts.account.name == account)
3308 {
3309 let target = format!("imap:{account}/{folder}");
3310 let mut errors: Vec<String> = Vec::new();
3311 let mut paths = Vec::new();
3312 let mut messages = Vec::new();
3313 for &i in &targets {
3314 match self.copy_bytes(i, decode) {
3315 Ok(bytes) => {
3316 paths.push(self.msgs[i].env.file.path.clone());
3317 messages.push((self.msgs[i].env.file.flags, bytes));
3318 }
3319 Err(err) => errors.push(err),
3320 }
3321 }
3322 if messages.is_empty() {
3323 let verb = if delete { "save" } else { "copy" };
3324 self.error(format!("cannot {verb}: {}", errors.join("; ")));
3325 return;
3326 }
3327 let job = Job::AppendAll {
3328 mailbox: folder.to_string(),
3329 messages,
3330 };
3331 self.send_then(
3332 job,
3333 true,
3334 Box::new(move |session, done| {
3335 let outcomes = match done {
3336 Ok(Done::Appended(outcomes)) => outcomes,
3337 Ok(_) => Vec::new(),
3338 Err(err) => vec![Err(format!("{err:#}"))],
3339 };
3340 let tried = outcomes.len();
3341 let mut copied = Vec::new();
3342 for (path, outcome) in paths.iter().zip(outcomes) {
3343 match outcome {
3344 Ok(()) => copied
3347 .extend(session.msgs.iter().position(|m| m.env.file.path == *path)),
3348 Err(err) => errors.push(err),
3349 }
3350 }
3351 if paths.len() > tried.max(1) {
3352 errors.push(format!("{} not tried", paths.len() - tried.max(1)));
3353 }
3354 let note = Some(format!("the copy in {target} stays"));
3355 session.finish_copy(Copied {
3356 copied,
3357 errors,
3358 created: Vec::new(),
3359 target,
3360 note,
3361 delete,
3362 tagged,
3363 });
3364 }),
3365 );
3366 return;
3367 }
3368 let mut created: Vec<PathBuf> = Vec::new();
3371 let mut copied = Vec::new();
3372 let mut errors: Vec<String> = Vec::new();
3373 let mut target = String::new();
3374 for &i in &targets {
3375 match self.copy_one(i, input, decode, &mut created) {
3376 Ok(shown) => {
3377 target = shown;
3378 copied.push(i);
3379 }
3380 Err(err) => errors.push(err),
3383 }
3384 }
3385 self.finish_copy(Copied {
3386 copied,
3387 errors,
3388 created,
3389 target,
3390 note: None,
3391 delete,
3392 tagged,
3393 });
3394 }
3395
3396 fn finish_copy(&mut self, done: Copied) {
3399 let Copied {
3400 copied,
3401 errors,
3402 created,
3403 target,
3404 note,
3405 delete,
3406 tagged,
3407 } = done;
3408 let verb = if delete { "save" } else { "copy" };
3409 if copied.is_empty() {
3410 self.error(format!("cannot {verb}: {}", errors.join("; ")));
3411 return;
3412 }
3413 let marks = copied.iter().map(|&i| self.mark(i)).collect();
3414 self.push_undo_step(UndoStep {
3415 what: format!("{verb} to {target}"),
3416 marks,
3417 sel: self.selected_path(),
3418 created,
3419 note,
3420 rewritten: Vec::new(),
3421 });
3422 let n = copied.len();
3423 if delete {
3424 let rules = self.delete_rules();
3425 for &i in &copied {
3426 if rules.mark(&mut self.msgs[i]) {
3427 self.msgs[i].dirty = true;
3428 }
3429 }
3430 }
3431 if delete
3435 && !tagged
3436 && errors.is_empty()
3437 && let Some(pos) = self.step_message(true, true)
3438 {
3439 self.sel = pos;
3440 }
3441 let mut status = match (delete, n) {
3442 (true, 1) => format!("saved to {target} (original marked deleted)"),
3443 (true, _) => format!("saved {n} to {target} (originals marked deleted)"),
3444 (false, 1) => format!("copied to {target}"),
3445 (false, _) => format!("copied {n} to {target}"),
3446 };
3447 if !errors.is_empty() {
3448 status += &format!("; {} failed: {}", errors.len(), errors.join("; "));
3449 self.error(status);
3450 } else {
3451 self.note(status);
3452 }
3453 }
3454
3455 fn copy_bytes(&mut self, i: usize, decode: bool) -> Result<Vec<u8>, String> {
3460 if decode {
3461 Ok(self.displayed_text(i)?.into_bytes())
3462 } else {
3463 self.message_bytes(i)
3464 .ok_or_else(|| "cannot read the message".into())
3465 }
3466 }
3467
3468 fn copy_one(
3473 &mut self,
3474 i: usize,
3475 spec: &str,
3476 decode: bool,
3477 created: &mut Vec<PathBuf>,
3478 ) -> Result<String, String> {
3479 if remote::parse_spec(spec).is_some() {
3480 return Err("can only save to a folder of the open account".into());
3481 }
3482 let flags = self.msgs[i].env.file.flags;
3483 let bytes = self.copy_bytes(i, decode)?;
3484 let dir = expand_tilde(spec);
3485 maildir::create(&dir)
3486 .and_then(|()| maildir::deliver(&dir, &bytes, flags))
3487 .map(|path| {
3488 created.push(path);
3489 dir.display().to_string()
3490 })
3491 .map_err(|err| format!("{err:#}"))
3492 }
3493
3494 pub fn create_alias(&mut self, nick: &str, addr: &str) {
3496 if nick.is_empty() || nick.contains(char::is_whitespace) {
3497 self.error("the alias nick must be one word");
3498 return;
3499 }
3500 match alias::append_to(self.config.mail.alias_file.as_deref(), nick, addr) {
3501 Ok(_) => self.note(format!("added: alias {nick} {addr}")),
3502 Err(err) => self.error(format!("cannot save the alias: {err:#}")),
3503 }
3504 }
3505
3506 pub fn pipe_message(&mut self, command: &str, tagged: bool) {
3510 if command.is_empty() {
3511 self.error("no command given");
3512 return;
3513 }
3514 let paths = self.target_paths(tagged);
3515 if !self.have_bodies(
3516 &paths,
3517 Again::Pipe {
3518 command: command.to_string(),
3519 tagged,
3520 },
3521 ) {
3522 return;
3523 }
3524 let targets = self.op_targets(tagged);
3525 let n = targets.len();
3526 let decode = self.config.mail.pipe_decode.unwrap_or(false);
3527 let split = self.config.mail.pipe_split.unwrap_or(false);
3528 let sep = self
3529 .config
3530 .mail
3531 .pipe_sep
3532 .clone()
3533 .unwrap_or_else(|| "\n".into());
3534 match self.run_over(&targets, command, decode, split, &sep) {
3535 Ok(()) => self.note(match n {
3536 1 => format!("piped to {command}"),
3537 _ => format!("piped {n} messages to {command}"),
3538 }),
3539 Err(err) => self.error(format!("pipe failed: {err}")),
3540 }
3541 }
3542
3543 pub fn bounce_current(&mut self, to: &str, tagged: bool) {
3546 let rcpts = compose::addresses(to);
3547 if rcpts.is_empty() {
3548 self.error(format!("cannot parse the addresses in {to:?}"));
3549 return;
3550 }
3551 let paths = self.target_paths(tagged);
3552 if !self.have_bodies(
3553 &paths,
3554 Again::Bounce {
3555 to: to.to_string(),
3556 tagged,
3557 },
3558 ) {
3559 return;
3560 }
3561 let targets = self.op_targets(tagged);
3562 let mut sent = 0usize;
3563 for i in targets {
3564 let Some(bytes) = self.message_bytes(i) else {
3565 return;
3566 };
3567 if let Err(err) = self.bounce_one(&bytes, to, &rcpts) {
3568 self.error(format!("bounce failed: {err:#}"));
3569 return;
3570 }
3571 sent += 1;
3572 }
3573 self.note(match sent {
3574 1 => format!("message bounced to {to}"),
3575 _ => format!("{sent} messages bounced to {to}"),
3576 });
3577 }
3578
3579 fn bounce_one(&mut self, bytes: &[u8], to: &str, rcpts: &[String]) -> Result<()> {
3582 let host = maildir::hostname();
3583 let from = self
3584 .current_identity(rcpts)
3585 .from_line()
3586 .unwrap_or_else(|| default_from(&host));
3587 let text = compose::bounce_text(
3588 bytes,
3589 &from,
3590 to,
3591 &compose::rfc2822_now(),
3592 &compose::make_message_id(&host),
3593 );
3594 let envelope_from = compose::bare_address(&from).unwrap_or_else(|| from.clone());
3595 let envelope = self.config.mail.envelope(&envelope_from);
3596 match self.smtp_account() {
3597 Some(account) => account_password(&account).and_then(|password| {
3598 smtp::send(
3599 &account,
3600 &password,
3601 &envelope_from,
3602 rcpts,
3603 text.as_bytes(),
3604 &envelope,
3605 )
3606 }),
3607 None => run_sendmail(
3608 text.as_bytes(),
3609 self.config.mail.sendmail.as_deref(),
3610 Some(rcpts),
3611 &envelope,
3612 ),
3613 }
3614 }
3615
3616 pub fn open_message(&mut self) {
3619 let Some(path) = self.selected_path() else {
3620 return;
3621 };
3622 if !self.have_bodies(std::slice::from_ref(&path), Again::View) {
3623 return;
3624 }
3625 self.mark_read();
3626 match self.load_view(&path) {
3627 Ok(view) => self.requests.push(Request::ShowMessage(Box::new(view))),
3628 Err(err) => self.error(format!("cannot open message: {err:#}")),
3629 }
3630 }
3631
3632 pub fn load_view(&mut self, path: &Path) -> Result<message::MessageView> {
3635 self.settle();
3639 if remote::is_partial(path)
3641 && let Some(imap) = &mut self.imap
3642 {
3643 imap.blocking(Job::FetchBodies(vec![path.to_path_buf()]))
3644 .context("cannot fetch message")?;
3645 if let Some(m) = self.msgs.iter_mut().find(|m| m.env.file.path == path)
3647 && let Ok(raw) = std::fs::read(path)
3648 {
3649 m.env.lines = Some(message::body_lines(&raw));
3650 }
3651 }
3652 let mut view = message::load_with(path, &self.display)?;
3653 if let Ok(raw) = std::fs::read(path)
3656 && let Some(p) = pgp::view(&self.config.pgp, &raw)
3657 {
3658 match p.body {
3659 Some(pgp::Body::Entity(raw)) => {
3663 view.body = message::render_entity(&raw, &self.display);
3664 }
3665 Some(pgp::Body::Text(text)) => view.body = text,
3666 None => {}
3667 }
3668 view.body = format!("{}\n\n{}", p.note, view.body);
3669 }
3670 Ok(view)
3671 }
3672
3673 fn displayed_text(&mut self, i: usize) -> Result<String, String> {
3677 let path = self.msgs[i].env.file.path.clone();
3678 let view = self
3679 .load_view(&path)
3680 .map_err(|err| format!("cannot decode: {err:#}"))?;
3681 let mut text = String::new();
3682 for (name, value) in &view.brief {
3683 text += &format!("{name}: {value}\n");
3684 }
3685 text.push('\n');
3686 text += &view.body;
3687 if !text.ends_with('\n') {
3688 text.push('\n');
3689 }
3690 Ok(text)
3691 }
3692
3693 pub fn print_current(&mut self, tagged: bool) {
3697 let paths = self.target_paths(tagged);
3698 if !self.have_bodies(&paths, Again::Print { tagged }) {
3699 return;
3700 }
3701 let targets = self.op_targets(tagged);
3702 if targets.is_empty() {
3703 return;
3704 }
3705 let command = self
3706 .config
3707 .mail
3708 .print
3709 .clone()
3710 .unwrap_or_else(|| "lpr".into());
3711 let decode = self.config.mail.print_decode.unwrap_or(true);
3712 let split = self.config.mail.print_split.unwrap_or(false);
3713 let n = targets.len();
3714 match self.run_over(&targets, &command, decode, split, "\n") {
3715 Ok(()) => self.note(match n {
3716 1 => format!("printed via {command}"),
3717 _ => format!("printed {n} messages via {command}"),
3718 }),
3719 Err(err) => self.error(format!("print failed: {err}")),
3720 }
3721 }
3722
3723 fn op_one_bytes(&mut self, i: usize, decode: bool) -> Result<Vec<u8>, String> {
3726 if decode {
3727 self.displayed_text(i).map(String::into_bytes)
3728 } else {
3729 self.message_bytes(i)
3730 .map(|mut b| {
3731 if !b.ends_with(b"\n") {
3732 b.push(b'\n');
3733 }
3734 b
3735 })
3736 .ok_or_else(|| "cannot read the message".into())
3737 }
3738 }
3739
3740 fn run_over(
3744 &mut self,
3745 targets: &[usize],
3746 command: &str,
3747 decode: bool,
3748 split: bool,
3749 sep: &str,
3750 ) -> Result<(), String> {
3751 if split {
3752 for &i in targets {
3753 let bytes = self.op_one_bytes(i, decode)?;
3754 pipe_to(command, &bytes).map_err(|err| format!("{err:#}"))?;
3755 }
3756 return Ok(());
3757 }
3758 let mut all: Vec<u8> = Vec::new();
3759 for (n, &i) in targets.iter().enumerate() {
3760 if n > 0 {
3761 all.extend_from_slice(sep.as_bytes());
3762 }
3763 all.extend_from_slice(&self.op_one_bytes(i, decode)?);
3764 }
3765 pipe_to(command, &all).map_err(|err| format!("{err:#}"))
3766 }
3767
3768 pub fn current_identity(&self, rcpts: &[String]) -> rmut_core::config::Identity {
3771 self.config.identity_for(
3772 &self.title,
3773 rcpts,
3774 self.imap.as_ref().map(|imap| &imap.facts.account),
3775 )
3776 }
3777
3778 pub fn matching_message_hooks(&self) -> Vec<usize> {
3780 let Some(env) = self.visible.get(self.sel).map(|&mi| &self.msgs[mi].env) else {
3781 return Vec::new();
3782 };
3783 let pos = pattern::Position {
3786 number: self.sel + 1,
3787 current: self.sel + 1,
3788 last: self.visible.len(),
3789 duplicate: false,
3790 };
3791 self.message_hooks
3792 .iter()
3793 .enumerate()
3794 .filter(|(_, h)| pattern::matches_in(&h.patterns, env, self.scope(pos), None))
3795 .map(|(i, _)| i)
3796 .collect()
3797 }
3798
3799 pub fn reply_hook_lines(&self, path: &Path) -> Vec<String> {
3803 if self.reply_hooks.is_empty() {
3804 return Vec::new();
3805 }
3806 let Some(env) = self.msgs.iter().find(|m| m.env.file.path == *path) else {
3807 return Vec::new();
3808 };
3809 let scope = self.scope(pattern::Position::default());
3810 self.reply_hooks
3811 .iter()
3812 .filter(|h| pattern::matches_in(&h.patterns, &env.env, scope, None))
3813 .map(|h| h.value.clone())
3814 .collect()
3815 }
3816
3817 pub fn fcc_hook_target(&self, draft: &str, path: &Path) -> Option<String> {
3820 if self.fcc_hooks.is_empty() {
3821 return None;
3822 }
3823 let env = compose::draft_envelope(draft, path);
3824 let scope = self.scope(pattern::Position::default());
3825 self.fcc_hooks
3826 .iter()
3827 .find(|h| pattern::matches_in(&h.patterns, &env, scope, None))
3828 .map(|h| h.value.clone())
3829 }
3830
3831 pub fn crypt_key_for(&self, address: &str) -> Option<String> {
3834 self.crypt_hooks
3835 .iter()
3836 .find(|(m, _)| m.is_match(address))
3837 .map(|(_, key)| key.clone())
3838 }
3839
3840 pub fn notmuch_mirror(&mut self, query: &str) -> Option<(PathBuf, usize)> {
3848 if query.is_empty() {
3849 return None;
3850 }
3851 let out = Command::new("notmuch")
3852 .args(["search", "--output=files", "--limit=1000", "--", query])
3853 .output();
3854 let out = match out {
3855 Ok(out) if out.status.success() => out,
3856 Ok(out) => {
3857 let err = String::from_utf8_lossy(&out.stderr);
3858 self.error(format!("notmuch: {}", err.trim()));
3859 return None;
3860 }
3861 Err(err) => {
3862 self.error(format!("notmuch: {err}"));
3863 return None;
3864 }
3865 };
3866 let stdout = String::from_utf8_lossy(&out.stdout);
3867 let files: Vec<&str> = stdout.lines().filter(|l| !l.trim().is_empty()).collect();
3868 if files.is_empty() {
3869 self.error("notmuch: no matches");
3870 return None;
3871 }
3872 if !self.ready_to_leave() {
3873 return None;
3874 }
3875 let dir = remote::cache_base().join("notmuch");
3876 let build = || -> Result<()> {
3877 for sub in ["cur", "new", "tmp"] {
3878 std::fs::create_dir_all(dir.join(sub))?;
3879 }
3880 for entry in std::fs::read_dir(dir.join("cur"))?.flatten() {
3881 let _ = std::fs::remove_file(entry.path());
3882 }
3883 for (i, file) in files.iter().enumerate() {
3884 let base = Path::new(file)
3885 .file_name()
3886 .map(|n| n.to_string_lossy().into_owned())
3887 .unwrap_or_else(|| format!("{i}"));
3888 let _ = std::os::unix::fs::symlink(
3891 file,
3892 dir.join("cur").join(format!("{i:04}.{base}")),
3893 );
3894 }
3895 Ok(())
3896 };
3897 if let Err(err) = build() {
3898 self.note(format!("notmuch mirror: {err:#}"));
3899 return None;
3900 }
3901 Some((dir, files.len()))
3902 }
3903
3904 pub fn compose_base(&self) -> Option<ComposeBase> {
3905 let &mi = self.visible.get(self.sel)?;
3906 let env = &self.msgs[mi].env;
3907 let view = message::load(&env.file.path).ok()?;
3908 let get = |name: &str| {
3909 view.all
3910 .iter()
3911 .filter(|(k, _)| k.eq_ignore_ascii_case(name))
3912 .map(|(_, v)| v.clone())
3913 .collect::<Vec<_>>()
3914 .join(", ")
3915 };
3916 let from_hdr = get("From");
3917 let from_me = !self.config.mail.reply_self && self.me().wrote(&from_hdr);
3920 let (reply_to, has_reply_to) = {
3921 let rt = get("Reply-To");
3922 if from_me && !get("To").trim().is_empty() {
3923 (get("To"), false)
3924 } else if rt.trim().is_empty() {
3925 (from_hdr.clone(), false)
3926 } else {
3927 let differs = rt.trim() != from_hdr.trim();
3928 (rt, differs)
3929 }
3930 };
3931 Some(ComposeBase {
3932 path: env.file.path.clone(),
3933 reply_to,
3934 from_hdr,
3935 has_reply_to,
3936 orig_to: get("To"),
3937 orig_cc: get("Cc"),
3938 list_post: compose::list_post_address(&get("List-Post")),
3939 followup_to: get("Mail-Followup-To"),
3940 from_addr: compose::addresses(&get("From"))
3941 .into_iter()
3942 .next()
3943 .unwrap_or_default(),
3944 from_display: env.from.clone(),
3945 subject: env.subject.clone(),
3946 date: env.date,
3947 msg_id: env.msg_id.clone(),
3948 references: env.references.clone(),
3949 })
3950 }
3951
3952 pub fn list_target(&self, base: &ComposeBase) -> Option<String> {
3958 if let Some(addr) = &base.list_post {
3959 return Some(addr.clone());
3960 }
3961 let mut candidates = compose::addresses(&base.orig_to);
3962 candidates.extend(compose::addresses(&base.orig_cc));
3963 candidates
3964 .into_iter()
3965 .find(|a| self.lists.iter().any(|m| m.is_match(a)))
3966 }
3967
3968 pub fn followup_header(&self, to: &str, cc: Option<&str>, from: &str) -> Option<String> {
3972 if self.lists.is_empty() {
3973 return None;
3974 }
3975 let mut rcpts = compose::addresses(to);
3976 rcpts.extend(compose::addresses(cc.unwrap_or_default()));
3977 if !rcpts
3978 .iter()
3979 .any(|a| self.lists.iter().any(|m| m.is_match(a)))
3980 {
3981 return None;
3982 }
3983 let subscribed = rcpts
3984 .iter()
3985 .any(|a| self.subscribed.iter().any(|m| m.is_match(a)));
3986 let value = compose::followup_to(to, cc.unwrap_or_default(), self.me(), subscribed, from);
3987 (!value.is_empty()).then_some(value)
3988 }
3989
3990 pub fn edit_headers(&self) -> bool {
3993 self.config.mail.edit_headers.unwrap_or(false)
3994 }
3995
3996 pub fn stage_draft(&self, text: &str) -> Result<(PathBuf, Option<String>)> {
4000 let text = compose::apply_my_hdr(text, &self.config.mail.my_hdr);
4004 if self.edit_headers() {
4005 return Ok((write_draft(&text)?, None));
4006 }
4007 let (head, body) = text.split_once("\n\n").unwrap_or((text.trim_end(), ""));
4008 Ok((write_draft(body)?, Some(head.to_string())))
4009 }
4010
4011 pub fn compose_from(&self, base: Option<&ComposeBase>, to: &str) -> Option<String> {
4015 let realname = self.config.identity.reverse_realname.unwrap_or(true);
4019 if self.config.identity.reverse_name
4020 && let Some(b) = base
4021 && let Some(from) = compose::reverse_from(&b.orig_to, &b.orig_cc, self.me(), realname)
4022 {
4023 let rcpts = compose::addresses(to);
4024 return Some(match self.current_identity(&rcpts).name {
4025 Some(name) if !realname && !from.contains('<') => format!("{name} <{from}>"),
4026 _ => from,
4027 });
4028 }
4029 let rcpts = compose::addresses(to);
4030 self.current_identity(&rcpts).from_line()
4031 }
4032
4033 pub fn forward_attaches(&self) -> bool {
4034 self.config.mail.forward.as_deref() == Some("attach")
4035 }
4036
4037 pub fn default_fcc(&self, draft: Option<&Compose>) -> String {
4045 if let Some(compose) = draft
4046 && let Ok(full) = draft_full(compose)
4047 && let Some(mailbox) = self.fcc_hook_target(&full, &compose.path)
4048 {
4049 return mailbox;
4050 }
4051 match &self.imap {
4052 Some(imap) => format!(
4053 "imap:{}/{}",
4054 imap.facts.account.name, imap.facts.account.sent_folder
4055 ),
4056 None => self.config.mail.sent.clone().unwrap_or_default(),
4057 }
4058 }
4059
4060 pub fn default_security(&self) -> Security {
4062 Self::combine(
4063 self.config.pgp.sign_by_default,
4064 self.config.pgp.encrypt_by_default,
4065 )
4066 }
4067
4068 fn combine(sign: bool, encrypt: bool) -> Security {
4069 match (sign, encrypt) {
4070 (true, true) => Security::Both,
4071 (true, false) => Security::Sign,
4072 (false, true) => Security::Encrypt,
4073 (false, false) => Security::None,
4074 }
4075 }
4076
4077 pub fn security_for(&self, kind: &ComposeKind, base: Option<&ComposeBase>) -> Security {
4084 let mut sign = self.config.pgp.sign_by_default;
4085 let mut encrypt = self.config.pgp.encrypt_by_default;
4086 let is_reply = matches!(
4087 kind,
4088 ComposeKind::Reply | ComposeKind::GroupReply | ComposeKind::ListReply
4089 );
4090 if is_reply
4091 && let Some(base) = base
4092 && let Ok(raw) = std::fs::read(&base.path)
4093 {
4094 let crypto = rmut_core::pgp::classify(&raw);
4095 if crypto.encrypted && self.config.pgp.reply_encrypt {
4096 encrypt = true;
4097 }
4098 if crypto.signed && self.config.pgp.reply_sign {
4099 sign = true;
4100 }
4101 if crypto.encrypted && self.config.pgp.reply_sign_encrypted {
4107 sign = true;
4108 }
4109 }
4110 Self::combine(sign, encrypt)
4111 }
4112
4113 pub fn tick_outbox(&mut self) {
4116 if self.holding() {
4119 return;
4120 }
4121 while self.outbox.first().is_some_and(|h| h.due <= Instant::now()) {
4122 let held = self.outbox.remove(0);
4123 self.deliver(held);
4124 }
4125 if let Some(next) = self.outbox.first()
4126 && !self.notice().is_some_and(|n| n.is_error())
4127 {
4128 let left = next.due.saturating_duration_since(Instant::now()).as_secs() + 1;
4129 self.note(format!("sending {} in {left}s (z cancels)", next.label));
4130 }
4131 }
4132
4133 pub fn flush_on_exit(&mut self) -> Vec<String> {
4140 let mut trouble = Vec::new();
4141 while !self.outbox.is_empty() {
4142 let held = self.outbox.remove(0);
4143 let label = held.label.clone();
4144 self.deliver(held);
4145 if let Some(err) = self.take_error() {
4146 trouble.push(format!("{label}: {err}"));
4147 }
4148 }
4149 self.wind_down();
4150 if let Some(err) = self.take_error() {
4151 trouble.push(err);
4152 }
4153 trouble
4154 }
4155
4156 fn take_error(&mut self) -> Option<String> {
4158 let err = self.notice().filter(|n| n.is_error()).map(|n| n.text())?;
4159 self.clear_notice();
4160 Some(err)
4161 }
4162
4163 pub fn wind_down(&mut self) {
4172 loop {
4173 self.settle();
4174 let Some(next) = self.deferred.pop_front() else {
4175 break;
4176 };
4177 if let Deferred::Job(job, pending) = next
4178 && matches!(
4179 pending,
4180 Pending::Sync { .. } | Pending::Then { hold: false, .. }
4181 )
4182 {
4183 self.start(job, pending);
4184 }
4185 }
4186 }
4187
4188 pub fn hold_send(&mut self, held: Held) {
4192 self.outbox.push(held);
4193 }
4194
4195 pub fn cancel_send(&mut self) -> bool {
4198 let Some(held) = self.outbox.pop() else {
4199 return false;
4200 };
4201 self.note(format!("send cancelled: {}", held.label));
4202 self.hand_back(held.state);
4203 true
4204 }
4205
4206 pub fn deliver(&mut self, held: Held) {
4211 self.settle();
4215 let Held {
4216 state: compose_state,
4217 text: final_text,
4218 fcc_text,
4219 fcc: chosen,
4220 ..
4221 } = held;
4222 let envelope = self
4223 .config
4224 .mail
4225 .envelope(&compose::from_address(&final_text).unwrap_or_default());
4226 let send_result = match self.smtp_account() {
4227 Some(account) => send_via_smtp(&account, &final_text, &envelope),
4228 None => run_sendmail(
4229 final_text.as_bytes(),
4230 self.config.mail.sendmail.as_deref(),
4231 None,
4232 &envelope,
4233 ),
4234 };
4235 let copy = fcc_text.unwrap_or(final_text);
4238 match send_result {
4239 Ok(()) => {
4240 let mut note = String::from("message sent");
4241 let mut fcc_to_server = None;
4242 let skip_copy = chosen.as_deref() == Some("")
4243 || (chosen.is_none() && self.config.mail.copy == Some(false));
4244 if skip_copy {
4245 } else if let Some(fcc) = chosen
4247 .as_deref()
4248 .filter(|f| Some(*f) != Some(self.default_fcc(None).as_str()))
4249 {
4250 let dir = expand_tilde(fcc);
4252 let flags = maildir::Flags {
4253 seen: true,
4254 ..Default::default()
4255 };
4256 if dir.join("cur").is_dir()
4257 && maildir::deliver(&dir, copy.as_bytes(), flags).is_ok()
4258 {
4259 note += &format!(", copy in {fcc}");
4260 } else {
4261 note += &format!(", Fcc to {fcc} failed");
4262 }
4263 } else {
4264 match &mut self.imap {
4265 Some(_) => fcc_to_server = Some(copy.into_bytes()),
4270 None => {
4271 let sent_dir = self
4272 .config
4273 .mail
4274 .sent
4275 .as_deref()
4276 .map(expand_tilde)
4277 .filter(|p| p.join("cur").is_dir())
4278 .or_else(|| {
4279 maildir::find_special(&self.dir, &["sent", "sent-mail"])
4280 });
4281 match sent_dir {
4282 Some(sent) => {
4283 let flags = maildir::Flags {
4284 seen: true,
4285 ..Default::default()
4286 };
4287 match maildir::deliver(&sent, copy.as_bytes(), flags) {
4288 Ok(_) => note += ", copy in Sent",
4289 Err(_) => note += ", Fcc to Sent failed",
4290 }
4291 }
4292 None => note += " (no Sent maildir, no copy kept)",
4293 }
4294 }
4295 }
4296 }
4297 if let Ok(full) = draft_full(&compose_state) {
4299 for a in compose::extract_attachments(&full).1 {
4300 if a.unlink {
4301 let _ = std::fs::remove_file(&a.path);
4302 }
4303 }
4304 }
4305 let _ = std::fs::remove_file(&compose_state.path);
4306 if let Some(src) = &compose_state.recall_source {
4307 let _ = std::fs::remove_file(src);
4308 }
4309 match fcc_to_server {
4310 Some(body) => self.fcc_to_sent(note, body),
4311 None => self.note(note),
4312 }
4313 }
4314 Err(err) => {
4315 self.error(format!("send failed: {err:#}"));
4316 self.hand_back(compose_state);
4317 }
4318 }
4319 }
4320
4321 fn fcc_to_sent(&mut self, sent: String, body: Vec<u8>) {
4325 self.send_then(
4326 Job::Append {
4327 mailbox: None,
4328 flags: maildir::Flags {
4329 seen: true,
4330 ..Default::default()
4331 },
4332 body,
4333 },
4334 false,
4335 Box::new(move |session, done| match done {
4336 Ok(Done::Folder(folder)) => session.note(format!("{sent}, copy in {folder}")),
4337 Ok(_) => session.note(format!("{sent}, copy in Sent")),
4338 Err(err) => session.error(format!("{sent}, Fcc to Sent failed: {err:#}")),
4339 }),
4340 );
4341 }
4342
4343 pub fn attachment_forgotten(&self, raw: &str, compose: &Compose) -> bool {
4348 if self.config.mail.abort_noattach.as_deref().unwrap_or("no") == "no" {
4349 return false;
4350 }
4351 if compose.attach.is_some() || !compose::extract_attachments(raw).1.is_empty() {
4352 return false;
4353 }
4354 let body = raw.split_once("\n\n").map_or("", |(_, body)| body);
4355 for line in body.lines() {
4356 if line.trim_end() == "--" || line == "-- " {
4357 break;
4358 }
4359 if self.quote_re.is_match(line) {
4360 continue;
4361 }
4362 if self.attach_re.is_match(line) {
4363 return true;
4364 }
4365 }
4366 false
4367 }
4368
4369 pub fn secure_message(
4373 &self,
4374 security: Security,
4375 text: String,
4376 files: &[compose::Attachment],
4377 original: Option<&[u8]>,
4378 markdown: bool,
4379 ) -> Result<String> {
4380 let cfg = &self.config.pgp;
4381 let recipients = |text: &str| -> Result<Vec<String>> {
4384 let (mut rcpts, _) = compose::smtp_envelope(text)?;
4385 if let Some(from) = compose::from_address(text) {
4386 rcpts.push(from);
4387 }
4388 for r in &mut rcpts {
4391 if let Some(key) = self.crypt_key_for(r) {
4392 *r = key;
4393 }
4394 }
4395 rcpts.sort();
4396 rcpts.dedup();
4397 Ok(rcpts)
4398 };
4399 let flowed = self.config.mail.text_flowed;
4400 if files.is_empty() && original.is_none() && !markdown {
4401 return match security {
4402 Security::None if flowed => Ok(compose::flow_plain(&text)),
4405 Security::None => Ok(text),
4406 Security::Sign => pgp::sign_message(cfg, &text, flowed),
4407 Security::Encrypt | Security::Both => pgp::encrypt_message(
4408 cfg,
4409 &recipients(&text)?,
4410 security == Security::Both,
4411 &text,
4412 flowed,
4413 ),
4414 };
4415 }
4416 let (head, body) = text.split_once("\n\n").unwrap_or((text.trim_end(), ""));
4417 let entity = match files.is_empty() && original.is_none() {
4418 true => compose::body_entity(body, flowed, markdown),
4419 false => compose::mixed_entity(body, files, original, flowed, markdown)?,
4420 };
4421 match security {
4422 Security::None => Ok(format!("{}\nMIME-Version: 1.0\n{entity}", head.trim_end())),
4423 Security::Sign => pgp::sign_entity(cfg, head, entity.as_bytes()),
4424 Security::Encrypt | Security::Both => pgp::encrypt_entity(
4425 cfg,
4426 &recipients(&text)?,
4427 security == Security::Both,
4428 head,
4429 entity.as_bytes(),
4430 ),
4431 }
4432 }
4433
4434 pub fn postponed_dir(&self) -> Option<PathBuf> {
4435 if let Some(dir) = self
4436 .config
4437 .mail
4438 .postponed
4439 .as_deref()
4440 .map(expand_tilde)
4441 .filter(|p| p.join("cur").is_dir())
4442 {
4443 return Some(dir);
4444 }
4445 if !self.in_cache() {
4446 return maildir::find_special(&self.dir, &["drafts", "postponed", "rmut-postponed"]);
4447 }
4448 let dir = postponed_fallback();
4453 let legacy = self.dir.join(".rmut-postponed");
4454 if legacy.join("cur").is_dir() && maildir::create(&dir).is_ok() {
4455 for file in maildir::scan(&legacy).unwrap_or_default() {
4456 if let Some(name) = file.path.file_name() {
4457 let _ = std::fs::rename(&file.path, dir.join("cur").join(name));
4458 }
4459 }
4460 }
4461 Some(dir).filter(|d| d.join("cur").is_dir())
4462 }
4463
4464 fn in_cache(&self) -> bool {
4466 self.dir.starts_with(remote::cache_base())
4467 }
4468
4469 pub fn has_postponed(&self) -> bool {
4470 self.postponed_dir()
4471 .and_then(|d| maildir::scan(&d).ok())
4472 .is_some_and(|files| !files.is_empty())
4473 }
4474
4475 pub fn postpone_draft(&mut self, compose_state: Compose) {
4478 let target = match self.postponed_dir() {
4479 Some(d) => Ok(d),
4480 None => {
4481 let d = match self.in_cache() {
4482 true => postponed_fallback(),
4483 false => self.dir.join(".rmut-postponed"),
4484 };
4485 maildir::create(&d).map(|()| d)
4486 }
4487 };
4488 let result = target.and_then(|dir| {
4489 let bytes = draft_full(&compose_state)?.into_bytes();
4492 let flags = maildir::Flags {
4493 draft: true,
4494 seen: true,
4495 ..Default::default()
4496 };
4497 maildir::deliver(&dir, &bytes, flags)
4498 });
4499 match result {
4500 Ok(path) => {
4501 let _ = std::fs::remove_file(&compose_state.path);
4502 self.note(format!("postponed to {}", path.display()));
4503 }
4504 Err(err) => {
4505 self.error(format!(
4506 "postpone failed: {err:#}; draft at {}",
4507 compose_state.path.display()
4508 ));
4509 }
4510 }
4511 }
4512
4513 pub fn recall_file(&mut self, source: PathBuf) -> Option<Compose> {
4516 let result = std::fs::read_to_string(&source)
4517 .map_err(anyhow::Error::from)
4518 .and_then(|content| self.stage_draft(&content));
4519 match result {
4520 Ok((path, hidden_head)) => Some(Compose {
4521 path,
4522 recall_source: Some(source),
4523 security: self.default_security(),
4524 attach: None,
4525 hidden_head,
4526 fcc: None,
4527 }),
4528 Err(err) => {
4529 self.error(format!("cannot recall: {err:#}"));
4530 None
4531 }
4532 }
4533 }
4534
4535 pub fn draft_head(&self) -> String {
4537 let Some(c) = &self.draft else {
4538 return String::new();
4539 };
4540 match &c.hidden_head {
4541 Some(head) => head.clone(),
4542 None => {
4543 let text = std::fs::read_to_string(&c.path).unwrap_or_default();
4544 match text.split_once("\n\n") {
4545 Some((head, _)) => head.to_string(),
4546 None => text.trim_end().to_string(),
4547 }
4548 }
4549 }
4550 }
4551
4552 pub fn edit_draft_head(&mut self, f: impl Fn(&str) -> String) {
4554 let Some(c) = &mut self.draft else {
4555 return;
4556 };
4557 match &mut c.hidden_head {
4558 Some(head) => *head = f(head),
4559 None => {
4560 if let Ok(text) = std::fs::read_to_string(&c.path) {
4561 let (head, body) = text.split_once("\n\n").unwrap_or((text.trim_end(), ""));
4562 let _ = std::fs::write(&c.path, format!("{}\n\n{body}", f(head)));
4563 }
4564 }
4565 }
4566 }
4567
4568 pub fn set_draft_header(&mut self, name: &str, value: &str) {
4570 let value = value.trim().to_string();
4571 let name = name.to_string();
4572 self.edit_draft_head(|head| {
4573 let mut lines: Vec<&str> = head
4574 .lines()
4575 .filter(|l| {
4576 !l.split_once(':')
4577 .is_some_and(|(k, _)| k.trim().eq_ignore_ascii_case(&name))
4578 })
4579 .collect();
4580 let added = format!("{name}: {value}");
4581 if !value.is_empty() {
4582 lines.push(&added);
4583 }
4584 lines.join("\n")
4585 });
4586 }
4587
4588 pub fn draft_header(&self, name: &str) -> String {
4589 header_value(&self.draft_head(), name).unwrap_or_default()
4590 }
4591
4592 pub fn send_draft(&mut self) -> Option<Ask> {
4596 let compose_state = self.draft.take()?;
4597 let raw = match draft_full(&compose_state) {
4598 Ok(r) => r,
4599 Err(err) => {
4600 self.error(format!("cannot read draft: {err}"));
4601 return None;
4602 }
4603 };
4604 if !mem::take(&mut self.attach_confirmed) && self.attachment_forgotten(&raw, &compose_state)
4607 {
4608 self.draft = Some(compose_state);
4609 match self.config.mail.abort_noattach.as_deref() {
4610 Some("yes") => {
4613 self.error("no attachment: not sent (abort_noattach); a attaches one");
4614 self.requests.push(Request::ShowDraft);
4615 return None;
4616 }
4617 _ => {
4618 return Some(Ask::Key {
4619 label:
4620 "The body mentions an attachment and none is attached. Send? (y/n): "
4621 .into(),
4622 what: AskKind::NoAttach,
4623 });
4624 }
4625 }
4626 }
4627 let keep_attachments = match self.fcc_attach_answer.take() {
4630 Some(answer) => answer,
4631 None => match self.config.mail.fcc_attach.as_deref().unwrap_or("yes") {
4632 "no" => false,
4633 ask @ ("ask-yes" | "ask-no")
4634 if compose_state.attach.is_some()
4635 || !compose::extract_attachments(&raw).1.is_empty() =>
4636 {
4637 self.draft = Some(compose_state);
4638 return Some(Ask::Key {
4639 label: "Save attachments in Fcc? (y/n): ".into(),
4640 what: AskKind::FccAttach {
4641 default_yes: ask == "ask-yes",
4642 },
4643 });
4644 }
4645 _ => true,
4646 },
4647 };
4648 let draft_path = self
4651 .draft
4652 .as_ref()
4653 .map(|c| c.path.clone())
4654 .unwrap_or_default();
4655 let hook_fcc = self.fcc_hook_target(&raw, &draft_path);
4656 let (raw, files) = compose::extract_attachments(&raw);
4657 let (raw, markdown) = self.take_markdown(&raw);
4658 let host = maildir::hostname();
4659 let from = self
4660 .current_identity(&[])
4661 .from_line()
4662 .unwrap_or_else(|| default_from(&host));
4663 let msg_host = self
4665 .config
4666 .mail
4667 .hostname
4668 .clone()
4669 .filter(|h| !h.trim().is_empty())
4670 .unwrap_or(host);
4671 let final_text = match compose::finalize_with(
4672 &raw,
4673 &from,
4674 &compose::make_message_id(&msg_host),
4675 &compose::rfc2822_now(),
4676 self.config.mail.user_agent.unwrap_or(false),
4677 ) {
4678 Ok(t) => t,
4679 Err(err) => {
4680 self.error(format!("{err}; press e to edit"));
4681 self.hand_back(compose_state);
4682 return None;
4683 }
4684 };
4685 let original = match &compose_state.attach {
4686 Some(path) => match std::fs::read(path) {
4687 Ok(bytes) => Some(bytes),
4688 Err(err) => {
4689 self.error(format!("cannot attach the original: {err}"));
4690 self.hand_back(compose_state);
4691 return None;
4692 }
4693 },
4694 None => None,
4695 };
4696 let fcc_security = match self.config.mail.fcc_clear {
4699 true => Security::None,
4700 false => compose_state.security,
4701 };
4702 let fcc_text = match (keep_attachments, fcc_security == compose_state.security) {
4703 (true, true) => Ok(None),
4704 (true, false) => self
4705 .secure_message(
4706 fcc_security,
4707 final_text.clone(),
4708 &files,
4709 original.as_deref(),
4710 markdown,
4711 )
4712 .map(Some),
4713 (false, _) => self
4714 .secure_message(fcc_security, final_text.clone(), &[], None, markdown)
4715 .map(Some),
4716 };
4717 let final_text = self
4718 .secure_message(
4719 compose_state.security,
4720 final_text,
4721 &files,
4722 original.as_deref(),
4723 markdown,
4724 )
4725 .and_then(|sent| Ok((sent, fcc_text?)));
4726 let (final_text, fcc_text) = match final_text {
4727 Ok(t) => t,
4728 Err(err) => {
4729 self.error(format!("{err:#}; e edits, s changes security"));
4730 self.hand_back(compose_state);
4731 return None;
4732 }
4733 };
4734 let held = Held {
4737 text: final_text,
4738 fcc_text,
4739 fcc: compose_state.fcc.clone().or(hook_fcc),
4740 label: {
4741 let head = raw.split_once("\n\n").map_or(raw.as_str(), |(h, _)| h);
4742 header_value(head, "Subject")
4743 .filter(|s| !s.trim().is_empty())
4744 .or_else(|| header_value(head, "To"))
4745 .unwrap_or_else(|| "message".into())
4746 },
4747 state: compose_state,
4748 due: Instant::now() + Duration::from_secs(self.config.mail.undo_send),
4749 };
4750 if self.config.mail.undo_send > 0 {
4752 self.note(format!(
4753 "sending {} in {}s (z cancels)",
4754 held.label, self.config.mail.undo_send
4755 ));
4756 self.hold_send(held);
4757 return None;
4758 }
4759 self.deliver(held);
4760 None
4761 }
4762
4763 fn take_markdown(&self, raw: &str) -> (String, bool) {
4766 let (raw, said) = compose::take_markdown(raw);
4767 (raw, said.unwrap_or(self.config.mail.markdown))
4768 }
4769
4770 pub fn draft_markdown(&self) -> bool {
4773 match header_value(&self.draft_head(), compose::MARKDOWN_HEADER) {
4774 Some(v) => matches!(v.trim().to_lowercase().as_str(), "yes" | "true" | "on"),
4775 None => self.config.mail.markdown,
4776 }
4777 }
4778
4779 pub fn toggle_draft_markdown(&mut self) {
4781 let on = !self.draft_markdown();
4782 self.set_draft_header(compose::MARKDOWN_HEADER, if on { "yes" } else { "no" });
4783 self.note(match on {
4784 true => "markdown: this draft goes out as text and html",
4785 false => "markdown off: this draft goes out as plain text",
4786 });
4787 }
4788
4789 fn confirm_attachment(&mut self) {
4792 self.attach_confirmed = true;
4793 }
4794
4795 fn hand_back(&mut self, draft: Compose) {
4797 self.draft = Some(draft);
4798 self.requests.push(Request::ShowDraft);
4799 }
4800
4801 pub fn draft(&self) -> Option<&Compose> {
4803 self.draft.as_ref()
4804 }
4805
4806 pub fn draft_mut(&mut self) -> Option<&mut Compose> {
4807 self.draft.as_mut()
4808 }
4809
4810 pub fn take_draft(&mut self) -> Option<Compose> {
4813 self.draft.take()
4814 }
4815
4816 pub fn set_draft(&mut self, draft: Compose) {
4823 if let Some((path, staged)) = self.staged.take()
4824 && self.config.mail.abort_unmodified.unwrap_or(true)
4825 && path == draft.path
4826 && std::fs::read_to_string(&draft.path).is_ok_and(|now| now == staged)
4827 {
4828 let _ = std::fs::remove_file(&draft.path);
4829 self.draft = None;
4830 self.error("aborted unmodified message");
4831 return;
4832 }
4833 self.draft = Some(draft);
4834 }
4835
4836 pub fn set_attach_field(&mut self, k: usize, input: &str, is_type: bool) {
4841 let value = input.trim().to_string();
4842 self.edit_draft_head(|head| {
4843 let mut seen = 0usize;
4844 head.lines()
4845 .map(|l| {
4846 let is_attach = l
4847 .split_once(':')
4848 .is_some_and(|(key, _)| key.trim().eq_ignore_ascii_case("attach"));
4849 if is_attach {
4850 let (_, mut atts) = compose::extract_attachments(l);
4853 if let Some(mut a) = atts.pop() {
4854 let idx = seen;
4855 seen += 1;
4856 if idx == k {
4857 let new = (!value.is_empty()).then(|| value.clone());
4858 if is_type {
4859 a.mime = new;
4860 } else {
4861 a.description = new;
4862 }
4863 return compose::attach_line(&a);
4864 }
4865 }
4866 }
4867 l.to_string()
4868 })
4869 .collect::<Vec<_>>()
4870 .join("\n")
4871 });
4872 }
4873
4874 pub fn attach_index(&mut self, sel: usize) -> Option<usize> {
4880 let fixed = 1 + usize::from(self.draft().is_some_and(|c| c.attach.is_some()));
4881 if sel < fixed {
4882 self.error("only Attach: files can be changed here");
4883 return None;
4884 }
4885 Some(sel - fixed)
4886 }
4887
4888 pub fn attachments(&self) -> Vec<compose::Attachment> {
4890 self.draft()
4891 .and_then(|c| draft_full(c).ok())
4892 .map(|full| compose::extract_attachments(&full).1)
4893 .unwrap_or_default()
4894 }
4895
4896 pub fn edit_attachment(&mut self, k: usize, f: impl Fn(&mut compose::Attachment)) {
4898 self.edit_draft_head(|head| {
4899 let mut seen = 0usize;
4900 head.lines()
4901 .map(|l| {
4902 let is_attach = l
4903 .split_once(':')
4904 .is_some_and(|(key, _)| key.trim().eq_ignore_ascii_case("attach"));
4905 if is_attach {
4906 let (_, mut atts) = compose::extract_attachments(l);
4907 if let Some(mut a) = atts.pop() {
4908 let idx = seen;
4909 seen += 1;
4910 if idx == k {
4911 f(&mut a);
4912 return compose::attach_line(&a);
4913 }
4914 }
4915 }
4916 l.to_string()
4917 })
4918 .collect::<Vec<_>>()
4919 .join("\n")
4920 });
4921 }
4922
4923 pub fn toggle_unlink(&mut self, sel: usize) {
4926 let Some(k) = self.attach_index(sel) else {
4927 return;
4928 };
4929 self.edit_attachment(k, |a| a.unlink = !a.unlink);
4930 let on = self.attachments().get(k).is_some_and(|a| a.unlink);
4931 self.note(if on {
4932 "the file will be deleted after sending"
4933 } else {
4934 "the file will be kept after sending"
4935 });
4936 }
4937
4938 pub fn toggle_disposition(&mut self, sel: usize) {
4940 let Some(k) = self.attach_index(sel) else {
4941 return;
4942 };
4943 self.edit_attachment(k, |a| a.inline = !a.inline);
4944 }
4945
4946 pub fn move_attachment(&mut self, sel: usize, up: bool) -> bool {
4949 let Some(k) = self.attach_index(sel) else {
4950 return false;
4951 };
4952 let n = self.attachments().len();
4953 let other = if up {
4954 k.checked_sub(1)
4955 } else {
4956 (k + 1 < n).then_some(k + 1)
4957 };
4958 let Some(other) = other else { return false };
4959 self.edit_draft_head(|head| {
4960 let mut lines: Vec<String> = head.lines().map(String::from).collect();
4961 let attach_rows: Vec<usize> = lines
4962 .iter()
4963 .enumerate()
4964 .filter(|(_, l)| {
4965 l.split_once(':')
4966 .is_some_and(|(key, _)| key.trim().eq_ignore_ascii_case("attach"))
4967 })
4968 .map(|(i, _)| i)
4969 .collect();
4970 if let (Some(&a), Some(&b)) = (attach_rows.get(k), attach_rows.get(other)) {
4971 lines.swap(a, b);
4972 }
4973 lines.join("\n")
4974 });
4975 true
4976 }
4977
4978 fn push_attach_line(&mut self, line: &str) -> std::io::Result<()> {
4980 let Some(c) = self.draft_mut() else {
4981 return Ok(());
4982 };
4983 match &mut c.hidden_head {
4984 Some(head) => {
4985 *head = format!("{}\n{line}", head.trim_end());
4986 Ok(())
4987 }
4988 None => std::fs::read_to_string(&c.path).and_then(|text| {
4989 let updated = match text.split_once("\n\n") {
4990 Some((head, body)) => format!("{head}\n{line}\n\n{body}"),
4991 None => format!("{}\n{line}\n", text.trim_end()),
4992 };
4993 std::fs::write(&c.path, updated)
4994 }),
4995 }
4996 }
4997
4998 pub fn attach_messages(&mut self) {
5004 if self.draft().is_none() {
5005 return;
5006 }
5007 let tagged = self.msgs.iter().any(|m| m.env.tagged);
5008 let paths = self.target_paths(tagged);
5009 let mut attached = 0usize;
5010 for path in paths {
5011 if remote::is_partial(&path) {
5012 self.error(format!(
5013 "{} is not fetched yet: open it first",
5014 path.file_name().and_then(|n| n.to_str()).unwrap_or("?")
5015 ));
5016 continue;
5017 }
5018 let mut a = compose::Attachment::of(path);
5019 a.mime = Some("message/rfc822".into());
5020 a.inline = true;
5021 let line = compose::attach_line(&a);
5022 match self.push_attach_line(&line) {
5023 Ok(()) => attached += 1,
5024 Err(err) => self.error(format!("cannot attach: {err}")),
5025 }
5026 }
5027 if attached > 0 {
5028 self.note(format!("attached {attached} message(s)"));
5029 }
5030 self.requests.push(Request::ShowDraft);
5031 }
5032
5033 pub fn new_mime(&mut self, path: &str, mime: &str) {
5037 if mime.is_empty() {
5038 self.requests.push(Request::ShowDraft);
5039 return;
5040 }
5041 if !mime.contains('/') || mime.starts_with('/') || mime.ends_with('/') {
5042 self.error("Content-Type is of the form base/sub");
5043 self.requests.push(Request::ShowDraft);
5044 return;
5045 }
5046 let file = expand_tilde(path);
5047 if !file.exists()
5048 && let Err(err) = std::fs::write(&file, b"")
5049 {
5050 self.error(format!("cannot create {}: {err}", file.display()));
5051 self.requests.push(Request::ShowDraft);
5052 return;
5053 }
5054 let mut a = compose::Attachment::of(file.clone());
5055 a.mime = Some(mime.to_string());
5056 let line = compose::attach_line(&a);
5057 if let Err(err) = self.push_attach_line(&line) {
5058 self.error(format!("cannot attach: {err}"));
5059 } else {
5060 self.requests.push(Request::EditFile(file));
5061 }
5062 self.requests.push(Request::ShowDraft);
5063 }
5064
5065 pub fn ispell_command(&self) -> Option<String> {
5068 let path = self.draft()?.path.display().to_string();
5069 let ispell = self.config.mail.ispell.as_deref().unwrap_or("ispell");
5070 Some(format!("{ispell} -x '{}'", path.replace('\'', "'\\''")))
5071 }
5072
5073 fn outgoing_text(&self, c: &Compose) -> Result<String> {
5076 let raw = draft_full(c).context("reading the draft")?;
5077 let (raw, files) = compose::extract_attachments(&raw);
5078 let (raw, markdown) = self.take_markdown(&raw);
5079 let host = maildir::hostname();
5080 let from = self
5081 .current_identity(&[])
5082 .from_line()
5083 .unwrap_or_else(|| default_from(&host));
5084 let msg_host = self
5085 .config
5086 .mail
5087 .hostname
5088 .clone()
5089 .filter(|h| !h.trim().is_empty())
5090 .unwrap_or(host);
5091 let text = compose::finalize_with(
5092 &raw,
5093 &from,
5094 &compose::make_message_id(&msg_host),
5095 &compose::rfc2822_now(),
5096 self.config.mail.user_agent.unwrap_or(false),
5097 )
5098 .map_err(|e| anyhow::anyhow!("{e}"))?;
5099 let original = match &c.attach {
5100 Some(path) => Some(std::fs::read(path).context("reading the original")?),
5101 None => None,
5102 };
5103 self.secure_message(c.security, text, &files, original.as_deref(), markdown)
5104 }
5105
5106 pub fn write_draft_to(&mut self, mailbox: &str) {
5110 let Some(c) = self.draft() else { return };
5111 let text = match self.outgoing_text(c) {
5112 Ok(t) => t,
5113 Err(err) => {
5114 self.error(format!("cannot write the message: {err:#}"));
5115 return;
5116 }
5117 };
5118 let flags = maildir::Flags {
5119 seen: true,
5120 ..Default::default()
5121 };
5122 match remote::parse_spec(mailbox) {
5123 Some((account, folder)) => {
5124 if !self
5125 .imap
5126 .as_ref()
5127 .is_some_and(|i| i.facts.account.name == account)
5128 {
5129 self.error("write-fcc to IMAP needs a folder of the open account");
5130 return;
5131 }
5132 let mailbox = mailbox.to_string();
5133 self.send_then(
5134 Job::Append {
5135 mailbox: Some(folder.to_string()),
5136 flags,
5137 body: text.into_bytes(),
5138 },
5139 false,
5140 Box::new(move |session, done| match done {
5141 Ok(_) => session.note(format!("Message written to {mailbox}.")),
5142 Err(err) => session.error(format!("write failed: {err:#}")),
5143 }),
5144 );
5145 }
5146 None => {
5147 let dir = expand_tilde(mailbox);
5148 if !dir.join("cur").is_dir() {
5149 self.error(format!("{mailbox} is not a maildir"));
5150 return;
5151 }
5152 match maildir::deliver(&dir, text.as_bytes(), flags) {
5153 Ok(_) => self.note(format!("Message written to {mailbox}.")),
5154 Err(err) => self.error(format!("write failed: {err:#}")),
5155 }
5156 }
5157 }
5158 }
5159
5160 pub fn detach(&mut self, sel: usize) {
5161 let fixed = 1 + usize::from(self.draft().is_some_and(|c| c.attach.is_some()));
5162 if sel < fixed {
5163 self.error("only Attach: files can be detached");
5164 return;
5165 }
5166 let k = sel - fixed;
5167 self.edit_draft_head(|head| {
5168 let mut seen = 0usize;
5169 head.lines()
5170 .filter(|l| {
5171 let is_attach = l
5172 .split_once(':')
5173 .is_some_and(|(key, _)| key.trim().eq_ignore_ascii_case("attach"));
5174 if is_attach {
5175 seen += 1;
5176 seen - 1 != k
5177 } else {
5178 true
5179 }
5180 })
5181 .collect::<Vec<_>>()
5182 .join("\n")
5183 });
5184 }
5185
5186 pub fn attach_file(&mut self, input: &str) {
5190 let input = input.trim();
5191 if !input.is_empty() {
5192 if !expand_tilde(input).is_file() {
5193 self.error(format!("{input} is not a file"));
5194 } else if let Some(c) = self.draft_mut() {
5195 let value = if input.contains(char::is_whitespace) && !input.starts_with('"') {
5198 format!("\"{input}\"")
5199 } else {
5200 input.to_string()
5201 };
5202 let result = match &mut c.hidden_head {
5203 Some(head) => {
5205 *head = format!("{}\nAttach: {value}", head.trim_end());
5206 Ok(())
5207 }
5208 None => std::fs::read_to_string(&c.path).and_then(|text| {
5209 let updated = match text.split_once("\n\n") {
5210 Some((head, body)) => format!("{head}\nAttach: {value}\n\n{body}"),
5211 None => format!("{}\nAttach: {value}\n", text.trim_end()),
5212 };
5213 std::fs::write(&c.path, updated)
5214 }),
5215 };
5216 if let Err(err) = result {
5217 self.error(format!("cannot attach: {err}"));
5218 }
5219 }
5220 }
5221 self.requests.push(Request::ShowDraft);
5222 }
5223
5224 pub fn error(&mut self, msg: impl Into<String>) {
5225 self.notify(Notice::Error(msg.into()));
5226 }
5227
5228 pub fn note(&mut self, msg: impl Into<String>) {
5230 self.notify(Notice::Info(msg.into()));
5231 }
5232
5233 fn notify(&mut self, notice: Notice) {
5238 if let Notice::Error(text) = ¬ice {
5239 let keep = self.config.ui.error_history;
5240 self.error_history.push_back(text.clone());
5241 while self.error_history.len() > keep {
5242 self.error_history.pop_front();
5243 }
5244 }
5245 if (notice.is_error() && self.config.ui.beep)
5248 || (notice.is_new_mail() && self.config.ui.beep_new)
5249 {
5250 use std::io::Write as _;
5251 let mut out = std::io::stdout();
5252 let _ = out.write_all(b"\x07");
5253 let _ = out.flush();
5254 }
5255 self.notices.notice(notice);
5256 self.spoken = true;
5257 }
5258
5259 pub fn notice(&self) -> Option<&Notice> {
5262 self.notices.latest()
5263 }
5264}
5265
5266fn subject_key(subject: &str) -> String {
5268 let mut key = subject.trim().to_lowercase();
5269 loop {
5270 let stripped = key
5271 .strip_prefix("re:")
5272 .or_else(|| key.strip_prefix("fwd:"))
5273 .or_else(|| key.strip_prefix("fw:"))
5274 .map(|rest| rest.trim_start().to_string());
5275 match stripped {
5276 Some(next) => key = next,
5277 None => break,
5278 }
5279 }
5280 key
5281}
5282
5283pub fn parse_sort(spec: &str) -> Option<(SortKey, bool)> {
5285 let (rev, name) = match spec.strip_prefix("reverse-") {
5286 Some(rest) => (true, rest),
5287 None => (false, spec),
5288 };
5289 let key = match name {
5290 "date" | "date-sent" | "date-received" => SortKey::Date,
5291 "from" => SortKey::From,
5292 "subject" => SortKey::Subject,
5293 "size" => SortKey::Size,
5294 "threads" => SortKey::Threads,
5295 "label" => SortKey::Label,
5296 "to" => SortKey::To,
5297 "unsorted" | "mailbox-order" => SortKey::Unsorted,
5298 _ => return None,
5299 };
5300 Some((key, rev && key != SortKey::Threads))
5302}
5303
5304impl pattern::EnvSource for Session {
5305 fn envelope(&self, i: usize) -> Option<&Envelope> {
5306 self.msgs.get(i).map(|m| &m.env)
5307 }
5308}
5309
5310fn dir_mtimes(dir: &Path) -> (Option<SystemTime>, Option<SystemTime>) {
5311 let mtime = |p: PathBuf| p.metadata().and_then(|m| m.modified()).ok();
5312 (mtime(dir.join("new")), mtime(dir.join("cur")))
5313}
5314
5315pub fn account_password(account: &Account) -> Result<String> {
5318 account_password_saying(account, &mut |_: &str| {})
5319}
5320
5321fn account_password_saying(account: &Account, say: &mut dyn FnMut(&str)) -> Result<String> {
5326 if !matches!(account.auth_kind()?, rmut_core::config::AuthKind::Password) {
5327 if account.token_command.is_some() {
5328 say(&format!("{}: running token_command...", account.name));
5329 }
5330 return account.secret();
5331 }
5332 static CACHE: Mutex<Option<HashMap<String, String>>> = Mutex::new(None);
5333 let mut cache = CACHE.lock().unwrap();
5334 let map = cache.get_or_insert_with(HashMap::new);
5335 if let Some(password) = map.get(&account.name) {
5336 return Ok(password.clone());
5337 }
5338 if account.password_command.is_some() {
5339 say(&format!(
5340 "{}: running password_command (a passphrase prompt may be waiting)...",
5341 account.name
5342 ));
5343 }
5344 let password = account.password()?;
5345 map.insert(account.name.clone(), password.clone());
5346 Ok(password)
5347}
5348
5349pub fn wrap_order(n: usize, sel: usize, forward: bool) -> Vec<(usize, bool)> {
5354 (1..=n)
5355 .map(|step| {
5356 if forward {
5357 ((sel + step) % n, sel + step >= n)
5358 } else {
5359 ((sel + n - (step % n)) % n, step > sel)
5360 }
5361 })
5362 .collect()
5363}
5364
5365pub fn sort_browser(dirs: &mut [(String, usize)], how: Option<&str>) {
5373 let how = how.unwrap_or("alpha");
5374 let (reverse, key) = match how.strip_prefix("reverse-") {
5375 Some(key) => (true, key),
5376 None => (false, how),
5377 };
5378 match key {
5379 "unsorted" => {}
5380 "count" | "unread" => dirs.sort_by_key(|(_, count)| *count),
5381 "date" => dirs.sort_by_cached_key(|(spec, _)| {
5382 if spec.starts_with("imap:") {
5383 return std::time::SystemTime::UNIX_EPOCH;
5384 }
5385 let dir = expand_tilde(spec);
5386 ["new", "cur"]
5387 .iter()
5388 .filter_map(|sub| std::fs::metadata(dir.join(sub)).ok())
5389 .filter_map(|m| m.modified().ok())
5390 .max()
5391 .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
5392 }),
5393 _ => dirs.sort_by(|a, b| a.0.cmp(&b.0)),
5394 }
5395 if reverse {
5396 dirs.reverse();
5397 }
5398}
5399
5400pub fn expand_tilde(input: &str) -> PathBuf {
5401 if let Some(rest) = input.strip_prefix("~/")
5402 && let Ok(home) = std::env::var("HOME")
5403 {
5404 return Path::new(&home).join(rest);
5405 }
5406 PathBuf::from(input)
5407}
5408
5409pub fn send_via_smtp(account: &Account, text: &str, envelope: &smtp::Envelope) -> Result<()> {
5410 let from = compose::from_address(text).context("cannot parse the From address")?;
5411 let (rcpts, text) = compose::smtp_envelope(text)?;
5412 anyhow::ensure!(!rcpts.is_empty(), "no recipient addresses");
5413 let password = account_password(account)?;
5414 smtp::send(account, &password, &from, &rcpts, text.as_bytes(), envelope)
5415}
5416
5417impl Session {
5418 pub fn open_url(&mut self, url: &str) {
5423 match spawn_url_opener(self.config.ui.url_command.as_deref(), url) {
5424 Ok(()) => self.note(format!("opening {url}")),
5425 Err(err) => self.error(format!("{err:#}")),
5426 }
5427 }
5428}
5429
5430pub fn spawn_url_opener(command: Option<&str>, url: &str) -> Result<()> {
5433 let default = if cfg!(target_os = "macos") {
5434 "open"
5435 } else {
5436 "xdg-open"
5437 };
5438 let command = command.filter(|c| !c.trim().is_empty()).unwrap_or(default);
5439 let mut words: Vec<String> = command.split_whitespace().map(String::from).collect();
5440 match words.iter_mut().find(|w| w.contains("%s")) {
5441 Some(word) => *word = word.replace("%s", url),
5442 None => words.push(url.to_string()),
5443 }
5444 let mut child = Command::new(&words[0])
5445 .args(&words[1..])
5446 .stdin(Stdio::null())
5447 .stdout(Stdio::null())
5448 .stderr(Stdio::null())
5449 .spawn()
5450 .with_context(|| format!("cannot run {}", words[0]))?;
5451 std::thread::spawn(move || child.wait());
5452 Ok(())
5453}
5454
5455pub fn pipe_to(command: &str, bytes: &[u8]) -> Result<()> {
5457 let mut child = Command::new("sh")
5458 .arg("-c")
5459 .arg(command)
5460 .stdin(Stdio::piped())
5461 .stdout(Stdio::null())
5462 .stderr(Stdio::null())
5463 .spawn()
5464 .with_context(|| format!("running {command}"))?;
5465 {
5469 let mut stdin = child.stdin.take().context("no stdin on the child")?;
5470 if let Err(err) = stdin.write_all(bytes)
5471 && err.kind() != std::io::ErrorKind::BrokenPipe
5472 {
5473 return Err(err.into());
5474 }
5475 }
5476 let status = child.wait()?;
5477 anyhow::ensure!(status.success(), "{command} exited with {status}");
5478 Ok(())
5479}
5480
5481pub fn run_sendmail(
5485 bytes: &[u8],
5486 configured: Option<&str>,
5487 rcpts: Option<&[String]>,
5488 envelope: &smtp::Envelope,
5489) -> Result<()> {
5490 let command = std::env::var("RMUT_SENDMAIL")
5491 .ok()
5492 .or_else(|| configured.map(String::from));
5493 let (prog, mut args) = match command {
5494 Some(v) => {
5495 let mut it = v.split_whitespace().map(String::from);
5496 let p = it.next().context("sendmail command is empty")?;
5497 (p, it.collect::<Vec<_>>())
5498 }
5499 None => {
5500 let p = if Path::new("/usr/sbin/sendmail").exists() {
5501 "/usr/sbin/sendmail".to_string()
5502 } else {
5503 "sendmail".to_string()
5504 };
5505 (p, Vec::new())
5506 }
5507 };
5508 if let Some(sender) = &envelope.sender {
5509 args.extend(["-f".into(), sender.clone()]);
5510 }
5511 if let Some(notify) = &envelope.notify {
5512 args.extend(["-N".into(), notify.clone()]);
5513 }
5514 if let Some(ret) = &envelope.ret {
5515 args.extend(["-R".into(), ret.clone()]);
5516 }
5517 match rcpts {
5518 Some(rcpts) => {
5519 args.extend(["-oi".into(), "--".into()]);
5522 args.extend(rcpts.iter().cloned());
5523 }
5524 None => args.extend(["-t".into(), "-oi".into()]),
5525 }
5526 let mut child = Command::new(&prog)
5527 .args(&args)
5528 .stdin(Stdio::piped())
5529 .stdout(Stdio::null())
5530 .stderr(Stdio::null())
5531 .spawn()
5532 .with_context(|| format!("running {prog}"))?;
5533 child
5534 .stdin
5535 .take()
5536 .context("no stdin on sendmail child")?
5537 .write_all(bytes)?;
5538 let status = child.wait()?;
5539 anyhow::ensure!(status.success(), "{prog} exited with {status}");
5540 Ok(())
5541}
5542
5543pub fn default_from(hostname: &str) -> String {
5544 if let Ok(email) = std::env::var("EMAIL") {
5545 return email;
5546 }
5547 let user = std::env::var("USER").unwrap_or_else(|_| "user".into());
5548 format!("{user}@{hostname}")
5549}
5550
5551const DEFAULT_ATTACH_KEYWORD: &str = r"\b(attach|attached|attaching|attachment|attachments)\b";
5554
5555fn attach_re_from_config(config: &Config, warnings: &mut Vec<String>) -> regex_lite::Regex {
5558 let spec = config
5559 .mail
5560 .attach_keyword
5561 .clone()
5562 .unwrap_or_else(|| DEFAULT_ATTACH_KEYWORD.to_string());
5563 match regex_lite::Regex::new(&format!("(?i){spec}")) {
5564 Ok(re) => re,
5565 Err(err) => {
5566 warnings.push(format!("bad attach_keyword {spec:?}: {err}"));
5567 default_attach_re()
5568 }
5569 }
5570}
5571
5572fn default_attach_re() -> regex_lite::Regex {
5573 regex_lite::Regex::new(&format!("(?i){DEFAULT_ATTACH_KEYWORD}")).expect("the default compiles")
5574}
5575
5576fn reply_re_from_config(config: &Config, warnings: &mut Vec<String>) -> regex_lite::Regex {
5579 match &config.mail.reply_regexp {
5580 Some(spec) => match compose::reply_regexp(spec) {
5581 Ok(re) => re,
5582 Err(err) => {
5583 warnings.push(format!("bad reply_regexp {spec:?}: {err}"));
5584 compose::default_reply_regexp()
5585 }
5586 },
5587 None => compose::default_reply_regexp(),
5588 }
5589}
5590
5591fn quote_re_from_config(config: &Config, warnings: &mut Vec<String>) -> regex_lite::Regex {
5594 match &config.pager.quote_regexp {
5595 Some(spec) => match regex_lite::Regex::new(spec) {
5596 Ok(re) => re,
5597 Err(err) => {
5598 warnings.push(format!("bad quote_regexp {spec:?}: {err}"));
5599 default_quote_re()
5600 }
5601 },
5602 None => default_quote_re(),
5603 }
5604}
5605
5606fn display_from_config(config: &Config) -> message::Display {
5609 let mut rules = message::HeaderRules::default();
5613 let clean = |list: &Vec<String>| {
5614 list.iter()
5615 .map(|n| n.trim_end_matches(':').to_lowercase())
5616 .collect::<Vec<_>>()
5617 };
5618 if let Some(list) = &config.pager.ignore {
5619 rules.ignore = clean(list);
5620 }
5621 if let Some(list) = &config.pager.unignore {
5622 rules.unignore = clean(list);
5623 }
5624 if let Some(list) = &config.pager.hdr_order {
5625 rules.order = clean(list);
5626 }
5627 let mut filters: HashMap<String, String> = HashMap::new();
5632 let mut mailcap: Option<Vec<rmut_core::mailcap::Entry>> = None;
5633 for (mimetype, command) in &config.filters {
5634 let mimetype = mimetype.to_lowercase();
5635 if !command.trim().is_empty() {
5636 filters.insert(mimetype, command.clone());
5637 continue;
5638 }
5639 let entries = mailcap.get_or_insert_with(rmut_core::mailcap::load);
5640 if let Some(command) = rmut_core::mailcap::command_for(entries, &mimetype) {
5641 filters.insert(mimetype, command);
5642 }
5643 }
5644 message::Display {
5645 filters,
5646 rules,
5647 reflow: config.pager.reflow_text.unwrap_or(true),
5648 alternative_order: config
5649 .pager
5650 .alternative_order
5651 .iter()
5652 .map(|t| t.to_lowercase())
5653 .collect(),
5654 html_to_text: config.pager.html.as_deref() != Some("raw"),
5655 }
5656}
5657
5658fn compile_hooks<'a>(
5661 what: &str,
5662 entries: impl Iterator<Item = (&'a String, &'a String)>,
5663 warnings: &mut Vec<String>,
5664) -> Vec<Hook> {
5665 let mut out = Vec::new();
5666 for (spec, value) in entries {
5667 if value.trim().is_empty() {
5668 warnings.push(format!("{what} {spec:?} has nothing to do"));
5669 continue;
5670 }
5671 match pattern::parse(spec) {
5672 Ok(patterns) => out.push(Hook {
5673 patterns,
5674 value: value.clone(),
5675 }),
5676 Err(err) => warnings.push(format!("bad {what} pattern {spec:?}: {err}")),
5677 }
5678 }
5679 out
5680}
5681
5682pub fn header_value(head: &str, name: &str) -> Option<String> {
5684 head.lines().find_map(|l| {
5685 let (k, v) = l.split_once(':')?;
5686 k.trim()
5687 .eq_ignore_ascii_case(name)
5688 .then(|| v.trim().to_string())
5689 })
5690}
5691
5692pub fn draft_full(c: &Compose) -> std::io::Result<String> {
5695 let text = std::fs::read_to_string(&c.path)?;
5696 Ok(match &c.hidden_head {
5697 Some(head) => format!("{}\n\n{}", head.trim_end(), text),
5698 None => text,
5699 })
5700}
5701
5702fn postponed_fallback() -> PathBuf {
5706 remote::data_base().join("postponed")
5707}
5708
5709pub fn default_quote_re() -> regex_lite::Regex {
5711 regex_lite::Regex::new(r"^([ \t]*[|>:}#])+").expect("default quote_regexp compiles")
5712}
5713
5714pub fn write_draft(text: &str) -> Result<PathBuf> {
5717 rmut_core::scratch::write("draft", ".eml", text.as_bytes())
5718}