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}
201
202#[derive(Clone, Copy, PartialEq, Eq, Debug)]
204pub enum Security {
205 None,
206 Sign,
207 Encrypt,
208 Both,
209}
210
211impl Security {
212 pub fn label(self) -> &'static str {
213 match self {
214 Security::None => "",
215 Security::Sign => "sign",
216 Security::Encrypt => "encrypt",
217 Security::Both => "sign+encrypt",
218 }
219 }
220}
221
222pub struct Compose {
224 pub path: PathBuf,
225 pub recall_source: Option<PathBuf>,
227 pub security: Security,
228 pub attach: Option<PathBuf>,
231 pub hidden_head: Option<String>,
234 pub fcc: Option<String>,
237}
238
239pub struct Held {
244 pub state: Compose,
245 pub text: String,
246 pub fcc: Option<String>,
249 pub label: String,
251 pub due: Instant,
252}
253
254#[derive(Clone, Copy)]
260pub struct DeleteRules {
261 pub flag_safe: bool,
262 pub untag: bool,
263}
264
265impl DeleteRules {
266 pub fn mark(self, m: &mut Msg) -> bool {
270 if self.flag_safe && m.env.file.flags.flagged {
271 return false;
272 }
273 m.env.file.flags.deleted = true;
274 if self.untag {
275 m.env.tagged = false;
276 }
277 true
278 }
279}
280
281enum Pending {
282 CheckNew,
284 Sync { purge: bool },
287 Again(Again),
290 Counts(Vec<String>),
292 Then { hold: bool, then: Then },
295}
296
297impl Pending {
298 fn holds(&self) -> bool {
299 matches!(self, Pending::Then { hold: true, .. })
300 }
301}
302
303type Then = Box<dyn FnOnce(&mut Session, Result<Done>) + Send>;
306
307enum Deferred {
309 Again(Again),
311 Job(Job, Pending),
313}
314
315impl Deferred {
316 fn holds(&self) -> bool {
317 matches!(self, Deferred::Job(_, pending) if pending.holds())
318 }
319
320 fn outlives_abort(&self) -> bool {
323 matches!(self, Deferred::Job(_, Pending::Then { hold: false, .. }))
324 }
325}
326
327struct Opening {
330 spec: String,
331 read_only: bool,
332 answer: Receiver<Result<(Session, Vec<String>)>>,
333 progress: Arc<Mutex<Option<String>>>,
334}
335
336struct Copied {
338 copied: Vec<usize>,
340 errors: Vec<String>,
341 created: Vec<PathBuf>,
343 target: String,
345 note: Option<String>,
347 delete: bool,
348 tagged: bool,
349}
350
351#[derive(Clone)]
354enum Again {
355 View,
357 Copy {
358 input: String,
359 delete: bool,
360 tagged: bool,
361 decode: bool,
362 },
363 Pipe {
364 command: String,
365 tagged: bool,
366 },
367 Print {
368 tagged: bool,
369 },
370 Bounce {
371 to: String,
372 tagged: bool,
373 },
374}
375
376pub struct Session {
378 pub dir: PathBuf,
381 pub title: String,
384 imap: Option<Imap>,
388 pub mbox: Option<mbox::Mbox>,
390 pub msgs: Vec<Msg>,
391 pub visible: Vec<usize>,
393 pub sel: usize,
395 pub sort: SortKey,
396 pub sort_rev: bool,
397 pub limit: Option<(String, Vec<Pattern>)>,
398 pub last_search: Option<Vec<Pattern>>,
399 pub search_rev: bool,
402 pub thread_depth: Vec<usize>,
405 pub thread_root: Vec<usize>,
406 pub thread_pseudo: Vec<bool>,
409 pub thread_parent: Vec<Option<usize>>,
413 pub thread_children: Vec<Vec<usize>>,
414 pub thread_members: Vec<Vec<usize>>,
415 pub collapsed: HashSet<PathBuf>,
417 undo: Vec<UndoStep>,
420 pub config: Config,
421 pub my_addresses: Vec<String>,
424 pub lists: Vec<pattern::Matcher>,
427 pub subscribed: Vec<pattern::Matcher>,
430 pub alternates: Vec<pattern::Matcher>,
433 body_hits: HashMap<String, HashSet<u32>>,
436 body_local: HashSet<String>,
438 server_folders: Option<Vec<(String, usize)>>,
441 listing: bool,
443 pub idle: Option<remote::IdleWatch>,
445 backfill: Option<remote::Backfill>,
447 mailbox_new: HashMap<String, usize>,
450 error_history: std::collections::VecDeque<String>,
453 mailbox_announced: HashSet<String>,
456 unseen: HashMap<String, usize>,
459 pub read_only: bool,
461 pub read_only_session: bool,
465 pub(crate) dir_mtimes: (Option<SystemTime>, Option<SystemTime>),
467 pub message_hooks: Vec<Hook>,
472 pub reply_hooks: Vec<Hook>,
473 fcc_hooks: Vec<Hook>,
474 crypt_hooks: Vec<(pattern::Matcher, String)>,
475 pub display: message::Display,
478 outbox: Vec<Held>,
481 pub quote_re: regex_lite::Regex,
484 pub reply_re: regex_lite::Regex,
486 attach_re: regex_lite::Regex,
488 pending: Option<Pending>,
491 deferred: std::collections::VecDeque<Deferred>,
495 opening: Option<Opening>,
498 progress_noted: bool,
501 spoken: bool,
505 aborted: bool,
508 quit_default: bool,
511 setup: Option<ComposeSetup>,
514 draft: Option<Compose>,
518 staged: Option<(PathBuf, String)>,
522 attach_confirmed: bool,
525 hook_base: Option<Box<Config>>,
528 active_message_hooks: Vec<usize>,
531 requests: Vec<Request>,
533 notices: Box<dyn NoticeSink>,
536}
537
538struct Silence;
540
541impl NoticeSink for Silence {
542 fn notice(&mut self, _notice: Notice) {}
543
544 fn latest(&self) -> Option<&Notice> {
545 None
546 }
547
548 fn clear(&mut self) {}
549}
550
551impl Session {
552 pub fn open(dir: &Path, config: Config) -> Result<(Session, Vec<String>)> {
558 let (envelopes, skipped) = hdrcache::load_envelopes(dir)?;
560 let mut msgs: Vec<Msg> = envelopes
561 .into_iter()
562 .map(|env| Msg {
563 env,
564 dirty: false,
565 purge: false,
566 })
567 .collect();
568 msgs.sort_by_key(|m| m.env.date);
570 let visible: Vec<usize> = (0..msgs.len()).collect();
571 let sel = msgs
573 .iter()
574 .position(|m| m.env.file.is_new)
575 .unwrap_or(visible.len().saturating_sub(1));
576 let mut warnings = Vec::new();
577 if skipped > 0 {
578 warnings.push(format!("{skipped} unreadable message(s) skipped"));
579 }
580 let count = msgs.len();
581 let mut me: Vec<String> = config
582 .identity
583 .email
584 .iter()
585 .chain(config.accounts.iter().map(|a| &a.user))
586 .chain(
587 config
588 .accounts
589 .iter()
590 .filter_map(|a| a.identity.as_ref().and_then(|i| i.email.as_ref())),
591 )
592 .chain(config.identities.iter().filter_map(|r| r.email.as_ref()))
593 .map(|a| a.to_lowercase())
594 .collect();
595 if let Ok(email) = std::env::var("EMAIL") {
596 me.push(email.to_lowercase());
597 }
598 let mut session = Session {
599 dir: dir.to_path_buf(),
600 title: dir.display().to_string(),
601 imap: None,
602 mbox: None,
603 msgs,
604 visible,
605 sel,
606 sort: SortKey::Date,
607 sort_rev: false,
608 limit: None,
609 last_search: None,
610 search_rev: false,
611 thread_depth: vec![0; count],
612 thread_root: (0..count).collect(),
613 thread_pseudo: vec![false; count],
614 thread_parent: vec![None; count],
615 thread_children: vec![Vec::new(); count],
616 thread_members: (0..count).map(|i| vec![i]).collect(),
617 collapsed: HashSet::new(),
618 undo: Vec::new(),
619 lists: config.list_matchers(),
620 subscribed: config.subscribed_matchers(),
621 alternates: config.alternate_matchers(),
622 my_addresses: me,
623 body_hits: HashMap::new(),
624 body_local: HashSet::new(),
625 server_folders: None,
626 listing: false,
627 idle: None,
628 backfill: None,
629 mailbox_new: HashMap::new(),
630 error_history: std::collections::VecDeque::new(),
631 mailbox_announced: HashSet::new(),
632 unseen: HashMap::new(),
633 read_only: false,
634 read_only_session: false,
635 dir_mtimes: dir_mtimes(dir),
636 message_hooks: Vec::new(),
637 reply_hooks: Vec::new(),
638 fcc_hooks: Vec::new(),
639 crypt_hooks: Vec::new(),
640 display: display_from_config(&config),
641 outbox: Vec::new(),
642 quote_re: default_quote_re(),
643 reply_re: compose::default_reply_regexp(),
644 attach_re: default_attach_re(),
645 config,
646 pending: None,
647 deferred: std::collections::VecDeque::new(),
648 opening: None,
649 progress_noted: false,
650 spoken: false,
651 aborted: false,
652 quit_default: true,
653 setup: None,
654 draft: None,
655 staged: None,
656 attach_confirmed: false,
657 hook_base: None,
658 active_message_hooks: Vec::new(),
659 requests: Vec::new(),
660 notices: Box::new(Silence),
661 };
662 session.apply_timeouts();
663 let mut hook_warnings = Vec::new();
664 session.quote_re = quote_re_from_config(&session.config, &mut hook_warnings);
665 session.reply_re = reply_re_from_config(&session.config, &mut hook_warnings);
666 session.attach_re = attach_re_from_config(&session.config, &mut hook_warnings);
667 session.compile_hooks_from_config(&mut hook_warnings);
668 hook_warnings.append(&mut warnings);
669 let mut warnings = hook_warnings;
670 if let Some(spec) = session.config.index.sort.clone() {
671 match parse_sort(&spec) {
672 Some((sort, rev)) => {
673 session.sort = sort;
674 session.sort_rev = rev;
675 session.resort(None);
676 }
677 None => warnings.push(format!("unknown sort {spec:?} in config")),
678 }
679 }
680 Ok((session, warnings))
681 }
682
683 pub fn open_spec(
690 spec: &str,
691 config: Config,
692 progress: remote::Progress,
693 ) -> Result<(Session, Vec<String>)> {
694 rmut_core::net::set_timeouts(config.net.connect_timeout, config.net.timeout);
697 rmut_core::net::set_trust(
698 config.net.system_cas,
699 config.net.certificate_file.as_deref().map(expand_tilde),
700 );
701 match remote::parse_spec(spec) {
702 Some((account_name, mailbox)) => {
703 let account = config
704 .account(account_name)
705 .with_context(|| format!("no account {account_name} in config"))?
706 .clone();
707 let password = account_password(&account)?;
708 Session::open_remote(&account, mailbox, &password, config, progress)
709 }
710 None => {
711 let path = expand_tilde(spec);
712 if path.is_file() {
713 return Session::open_mbox(&path, config);
714 }
715 Session::open(&path, config)
716 }
717 }
718 }
719
720 fn open_remote(
725 account: &Account,
726 mailbox: &str,
727 password: &str,
728 config: Config,
729 progress: remote::Progress,
730 ) -> Result<(Session, Vec<String>)> {
731 let remote = Remote::open(account, mailbox, password, progress)?;
732 let cache = remote.cache.clone();
733 let (mut session, warnings) = Session::open(&cache, config)?;
734 session.title = remote.spec.clone();
735 session.idle = Some(remote::idle_watch(account, &remote.mailbox, password));
738 session.imap = Some(Imap::new(remote));
739 Ok((session, warnings))
740 }
741
742 pub fn open_mbox(path: &Path, config: Config) -> Result<(Session, Vec<String>)> {
745 let mbox = mbox::Mbox::open(path)?;
746 let cache = mbox.cache.clone();
747 let (mut session, warnings) = Session::open(&cache, config)?;
748 session.title = path.display().to_string();
749 session.mbox = Some(mbox);
750 Ok((session, warnings))
751 }
752
753 pub fn recompile(&mut self) -> Vec<String> {
756 let mut warnings = Vec::new();
757 self.apply_timeouts();
758 self.display = display_from_config(&self.config);
759 self.quote_re = quote_re_from_config(&self.config, &mut warnings);
760 self.reply_re = reply_re_from_config(&self.config, &mut warnings);
761 self.attach_re = attach_re_from_config(&self.config, &mut warnings);
762 self.compile_hooks_from_config(&mut warnings);
763 self.lists = self.config.list_matchers();
764 self.subscribed = self.config.subscribed_matchers();
765 self.alternates = self.config.alternate_matchers();
766 warnings
767 }
768
769 fn apply_timeouts(&self) {
773 rmut_core::net::set_timeouts(self.config.net.connect_timeout, self.config.net.timeout);
774 rmut_core::net::set_trust(
775 self.config.net.system_cas,
776 self.config
777 .net
778 .certificate_file
779 .as_deref()
780 .map(expand_tilde),
781 );
782 }
783
784 fn compile_hooks_from_config(&mut self, warnings: &mut Vec<String>) {
787 self.message_hooks = compile_hooks(
788 "message-hook",
789 self.config
790 .message_hooks
791 .iter()
792 .map(|h| (&h.pattern, &h.command)),
793 warnings,
794 );
795 self.reply_hooks = compile_hooks(
796 "reply-hook",
797 self.config
798 .reply_hooks
799 .iter()
800 .map(|h| (&h.pattern, &h.command)),
801 warnings,
802 );
803 self.fcc_hooks = compile_hooks(
804 "fcc-hook",
805 self.config
806 .fcc_hooks
807 .iter()
808 .map(|h| (&h.pattern, &h.mailbox)),
809 warnings,
810 );
811 self.crypt_hooks = self
812 .config
813 .crypt_hooks
814 .iter()
815 .map(|h| (pattern::Matcher::new(&h.address), h.key.clone()))
816 .collect();
817 }
818
819 pub fn switch_to(&mut self, spec: &str, read_only: bool) {
835 if self.holding() {
836 self.error("still opening the last one (Ctrl+G gives up on it)");
837 return;
838 }
839 let Some((account, mailbox)) = remote::parse_spec(spec) else {
840 let config = self.config.clone();
841 match Session::open_spec(spec, config, Box::new(|_| {})) {
842 Ok((next, warnings)) => self.become_(next, warnings, read_only),
843 Err(err) => self.error(format!("cannot open {spec}: {err:#}")),
844 }
845 return;
846 };
847 let same_account = self
848 .imap
849 .as_ref()
850 .is_some_and(|imap| imap.facts.account.name == account);
851 if !same_account {
852 self.open_in_background(spec, read_only);
853 return;
854 }
855 let spec = spec.to_string();
856 self.send_then(
857 Job::Switch(mailbox.into()),
858 true,
859 Box::new(move |session, done| match done {
860 Ok(_) => session.switched(read_only),
861 Err(_) => session.open_in_background(&spec, read_only),
864 }),
865 );
866 }
867
868 pub fn holding(&self) -> bool {
872 self.opening.is_some()
873 || self.pending.as_ref().is_some_and(Pending::holds)
874 || self.deferred.iter().any(Deferred::holds)
875 }
876
877 fn switched(&mut self, read_only: bool) {
880 let Some(imap) = self.imap.take() else {
881 return;
882 };
883 match Session::open(&imap.facts.cache.clone(), self.config.clone()) {
884 Ok((mut next, warnings)) => {
885 next.title = imap.facts.spec.clone();
886 if let Ok(password) = account_password(&imap.facts.account) {
888 next.idle = Some(remote::idle_watch(
889 &imap.facts.account,
890 &imap.facts.mailbox,
891 &password,
892 ));
893 }
894 next.imap = Some(imap);
895 next.deferred = mem::take(&mut self.deferred)
898 .into_iter()
899 .filter(Deferred::outlives_abort)
900 .collect();
901 self.become_(next, warnings, read_only);
902 }
903 Err(err) => self.error(format!("cannot open {}: {err:#}", imap.facts.spec)),
904 }
905 }
906
907 fn open_in_background(&mut self, spec: &str, read_only: bool) {
910 let Some((account_name, mailbox)) = remote::parse_spec(spec) else {
911 return;
912 };
913 let prepared = self
914 .config
915 .account(account_name)
916 .with_context(|| format!("no account {account_name} in config"))
917 .cloned()
918 .and_then(|account| account_password(&account).map(|password| (account, password)));
919 let (account, password) = match prepared {
920 Ok(prepared) => prepared,
921 Err(err) => {
922 self.error(format!("cannot open {spec}: {err:#}"));
923 return;
924 }
925 };
926 let progress = Arc::new(Mutex::new(None));
927 let (tell, answer) = std::sync::mpsc::channel();
928 let config = self.config.clone();
929 let mailbox = mailbox.to_string();
930 let sink = Imap::progress_sink(&progress);
931 std::thread::spawn(move || {
932 let opened = Session::open_remote(&account, &mailbox, &password, config, sink);
933 let _ = tell.send(opened);
935 });
936 self.note_progress("opening the folder... (Ctrl+G aborts)".into());
937 self.opening = Some(Opening {
938 spec: spec.to_string(),
939 read_only,
940 answer,
941 progress,
942 });
943 }
944
945 fn become_(&mut self, mut next: Session, warnings: Vec<String>, read_only: bool) {
950 next.read_only_session = self.read_only_session;
953 next.read_only = self.read_only_session || read_only;
954 next.notices = mem::replace(&mut self.notices, Box::new(Silence));
955 next.outbox = mem::take(&mut self.outbox);
956 *self = next;
957 self.maybe_backfill();
959 self.requests.push(Request::Opened(warnings));
960 }
961
962 pub fn install_notices(&mut self, sink: Box<dyn NoticeSink>) {
965 self.notices = sink;
966 }
967
968 pub fn clear_notice(&mut self) {
971 self.notices.clear();
972 self.progress_noted = false;
974 self.spoken = false;
975 }
976
977 pub fn new_count(&self) -> usize {
978 self.msgs.iter().filter(|m| m.env.file.is_new).count()
979 }
980
981 pub fn deleted_count(&self) -> usize {
982 self.msgs
983 .iter()
984 .filter(|m| m.env.file.flags.deleted)
985 .count()
986 }
987
988 pub fn pending_count(&self) -> usize {
989 self.msgs.iter().filter(|m| m.pending()).count()
990 }
991
992 fn thread_has_unread(&self, root: usize) -> bool {
994 self.thread_root
995 .iter()
996 .enumerate()
997 .filter(|&(_, &r)| r == root)
998 .any(|(i, _)| !self.msgs[i].env.file.flags.seen)
999 }
1000
1001 fn first_unread_in_thread(&self, root: usize) -> Option<PathBuf> {
1004 self.thread_root
1005 .iter()
1006 .enumerate()
1007 .filter(|&(_, &r)| r == root)
1008 .map(|(i, _)| i)
1009 .find(|&i| !self.msgs[i].env.file.flags.seen)
1010 .map(|i| self.msgs[i].env.file.path.clone())
1011 }
1012
1013 pub fn thread_info(&self, mi: usize) -> (usize, Option<usize>) {
1015 if self.sort != SortKey::Threads {
1016 return (0, None);
1017 }
1018 let depth = self.thread_depth.get(mi).copied().unwrap_or(0);
1019 if depth == 0 && self.collapsed.contains(&self.msgs[mi].env.file.path) {
1020 let hidden = self
1021 .thread_root
1022 .iter()
1023 .enumerate()
1024 .filter(|&(j, &r)| r == mi && j != mi)
1025 .count();
1026 if hidden > 0 {
1027 return (0, Some(hidden));
1028 }
1029 }
1030 (depth, None)
1031 }
1032
1033 pub fn select(&mut self, index: usize) {
1034 if self.visible.is_empty() {
1035 return;
1036 }
1037 self.sel = index.min(self.visible.len() - 1);
1038 }
1039
1040 pub fn cur_mut(&mut self) -> Option<&mut Msg> {
1041 let i = self.visible.get(self.sel).copied()?;
1042 self.msgs.get_mut(i)
1043 }
1044
1045 pub fn selected_path(&self) -> Option<PathBuf> {
1046 self.visible
1047 .get(self.sel)
1048 .map(|&i| self.msgs[i].env.file.path.clone())
1049 }
1050
1051 pub fn deny_readonly(&mut self) -> bool {
1054 if self.read_only {
1055 self.error("Mailbox is read-only.");
1056 }
1057 self.read_only
1058 }
1059
1060 pub fn mark_read(&mut self) {
1061 if self.read_only {
1062 return;
1063 }
1064 if let Some(m) = self.cur_mut()
1065 && (m.env.file.is_new || !m.env.file.flags.seen)
1066 {
1067 m.env.file.is_new = false;
1068 m.env.file.flags.seen = true;
1069 m.dirty = true;
1070 }
1071 }
1072
1073 pub fn scope(&self, position: pattern::Position) -> pattern::Scope<'_> {
1076 pattern::Scope {
1077 me: self.me(),
1078 lists: &self.lists,
1079 subscribed: &self.subscribed,
1080 position,
1081 thread: None,
1082 }
1083 }
1084
1085 pub fn scope_at(&self, position: pattern::Position, mi: usize) -> pattern::Scope<'_> {
1089 let thread = (self.sort == SortKey::Threads && mi < self.msgs.len()).then(|| {
1090 let root = self.thread_root.get(mi).copied().unwrap_or(mi);
1091 pattern::ThreadView {
1092 members: self.thread_members.get(root).map_or(&[][..], Vec::as_slice),
1093 parent: self.thread_parent.get(mi).copied().flatten(),
1094 children: self.thread_children.get(mi).map_or(&[][..], Vec::as_slice),
1095 collapsed: self.thread_depth.get(mi) == Some(&0)
1096 && self.collapsed.contains(&self.msgs[mi].env.file.path),
1097 envs: self,
1098 }
1099 });
1100 pattern::Scope {
1101 thread,
1102 ..self.scope(position)
1103 }
1104 }
1105
1106 pub fn me(&self) -> pattern::Me<'_> {
1108 pattern::Me::new(&self.my_addresses, &self.alternates)
1109 }
1110
1111 fn env_matches_at(
1116 &self,
1117 patterns: &[Pattern],
1118 env: &Envelope,
1119 pos: pattern::Position,
1120 mi: usize,
1121 ) -> bool {
1122 pattern::matches_in(
1123 patterns,
1124 env,
1125 self.scope_at(pos, mi),
1126 Some(&|env: &Envelope, m: &pattern::Matcher| {
1127 let set = self.body_hits.get(m.raw())?;
1128 let uid = remote::uid_of(&env.file.path)?;
1129 Some(set.contains(&uid))
1130 }),
1131 )
1132 }
1133
1134 fn positions(&self) -> Vec<pattern::Position> {
1139 let mut seen: HashMap<&str, usize> = HashMap::new();
1140 for m in &self.msgs {
1141 if let Some(id) = m.env.msg_id.as_deref() {
1142 *seen.entry(id).or_default() += 1;
1143 }
1144 }
1145 let mut numbers = vec![0usize; self.msgs.len()];
1146 for (n, &mi) in self.visible.iter().enumerate() {
1147 if let Some(slot) = numbers.get_mut(mi) {
1148 *slot = n + 1;
1149 }
1150 }
1151 let current = self
1152 .visible
1153 .get(self.sel)
1154 .and_then(|&mi| numbers.get(mi).copied())
1155 .unwrap_or(0);
1156 let last = self.visible.len();
1157 (0..self.msgs.len())
1158 .map(|i| pattern::Position {
1159 number: numbers[i],
1160 current,
1161 last,
1162 duplicate: self.msgs[i]
1163 .env
1164 .msg_id
1165 .as_deref()
1166 .is_some_and(|id| seen.get(id).copied().unwrap_or(0) > 1),
1167 })
1168 .collect()
1169 }
1170
1171 fn body_terms_ready(
1181 &mut self,
1182 patterns: &[Pattern],
1183 redo: Box<dyn FnOnce(&mut Session) + Send>,
1184 ) -> bool {
1185 if self.imap.is_none() {
1186 return true;
1187 }
1188 let Some(term) = pattern::body_terms(patterns).into_iter().find(|term| {
1189 let simple = !term
1190 .chars()
1191 .any(|c| r".*+?[](){}|^$\".contains(c) || !c.is_ascii());
1192 simple && !self.body_hits.contains_key(term) && !self.body_local.contains(term)
1193 }) else {
1194 return true;
1195 };
1196 self.send_then(
1197 Job::SearchBody(term.clone()),
1198 true,
1199 Box::new(move |session, done| {
1200 match done {
1201 Ok(Done::Uids(uids)) => {
1202 session.body_hits.insert(term, uids.into_iter().collect());
1203 }
1204 _ => {
1205 session.body_local.insert(term);
1206 }
1207 }
1208 redo(session);
1209 }),
1210 );
1211 false
1212 }
1213
1214 pub fn rescan(&mut self) {
1217 let Ok(files) = maildir::scan(&self.dir) else {
1218 return;
1219 };
1220 let keep = self.selected_path();
1221 let mut old: std::collections::HashMap<PathBuf, Msg> = self
1222 .msgs
1223 .drain(..)
1224 .map(|m| (m.env.file.path.clone(), m))
1225 .collect();
1226 let mut arrived = 0usize;
1227 let mut arrivals: Vec<PathBuf> = Vec::new();
1228 for file in files {
1229 match old.remove(&file.path) {
1230 Some(prev) if prev.pending() => self.msgs.push(prev),
1231 Some(mut prev) => {
1232 prev.env.file = file;
1234 self.msgs.push(prev);
1235 }
1236 None => {
1237 if let Ok(env) = message::envelope(file) {
1238 if env.file.is_new {
1239 arrived += 1;
1240 arrivals.push(env.file.path.clone());
1241 }
1242 self.msgs.push(Msg {
1243 env,
1244 dirty: false,
1245 purge: false,
1246 });
1247 }
1248 }
1249 }
1250 }
1251 self.dir_mtimes = dir_mtimes(&self.dir);
1252 self.resort(keep.clone());
1253 if self.sort == SortKey::Threads
1256 && self.config.index.uncollapse_new.unwrap_or(true)
1257 && !self.collapsed.is_empty()
1258 {
1259 let mut unfolded = false;
1260 for path in &arrivals {
1261 if let Some(mi) = self.msgs.iter().position(|m| &m.env.file.path == path) {
1262 let root = self.thread_root.get(mi).copied().unwrap_or(mi);
1263 unfolded |= self.collapsed.remove(&self.msgs[root].env.file.path);
1264 }
1265 }
1266 if unfolded {
1267 self.rebuild_visible(keep);
1268 }
1269 }
1270 if arrived > 0 {
1271 let title = self.title.clone();
1272 self.notify(Notice::NewMail(format!("new mail in {title} (+{arrived})")));
1273 self.run_new_mail_command(&title, arrived);
1274 }
1275 }
1276
1277 fn run_new_mail_command(&self, mailbox: &str, count: usize) {
1280 let Some(cmd) = &self.config.mail.new_mail_command else {
1281 return;
1282 };
1283 let cmd = cmd
1284 .replace("%f", &format!("'{}'", mailbox.replace('\'', r"'\''")))
1285 .replace("%n", &count.to_string());
1286 let _ = Command::new("sh")
1287 .arg("-c")
1288 .arg(cmd)
1289 .stdout(Stdio::null())
1290 .stderr(Stdio::null())
1291 .spawn();
1292 }
1293
1294 pub fn check_new_mail(&mut self) {
1301 if self.pending.is_some() {
1304 return;
1305 }
1306 let backfilling = self.backfill.as_ref().is_some_and(|b| !b.done());
1307 if !backfilling && self.start(Job::CheckNew, Pending::CheckNew) {
1311 return;
1312 }
1313 self.check_local_mail();
1314 }
1315
1316 fn check_local_mail(&mut self) {
1319 self.maybe_backfill();
1320 if let Some(mbox) = &mut self.mbox {
1321 if let Err(err) = mbox.refresh() {
1323 self.error(format!("mbox: {err:#}"));
1324 }
1325 }
1326 self.check_other_mailboxes();
1327 self.requests.push(Request::MailboxesChanged);
1329 let check_new = self.config.mail.check_new.unwrap_or(true) || self.imap.is_some();
1333 if check_new && dir_mtimes(&self.dir) != self.dir_mtimes {
1334 self.rescan();
1335 }
1336 if self.deferred.is_empty() {
1339 self.refresh_unseen();
1340 }
1341 }
1342
1343 pub fn poll_network(&mut self) {
1347 self.poll_opening();
1348 if let Some(line) = self.imap.as_mut().and_then(Imap::take_progress) {
1349 if !self.aborted {
1352 self.note_progress(format!("{line} (Ctrl+G aborts)"));
1353 }
1354 }
1355 let Some(done) = self.imap.as_mut().and_then(Imap::collect) else {
1356 return;
1357 };
1358 if mem::take(&mut self.progress_noted) {
1361 self.clear_notice();
1362 }
1363 if let Some(pending) = self.pending.take() {
1364 self.resume(pending, done);
1365 }
1366 while self.pending.is_none()
1369 && let Some(next) = self.deferred.pop_front()
1370 {
1371 match next {
1372 Deferred::Again(again) => self.run_again(again),
1373 Deferred::Job(job, pending) => {
1374 self.start(job, pending);
1375 }
1376 }
1377 }
1378 }
1379
1380 fn poll_opening(&mut self) {
1383 let Some(opening) = &self.opening else {
1384 return;
1385 };
1386 let line = opening
1387 .progress
1388 .lock()
1389 .ok()
1390 .and_then(|mut slot| slot.take());
1391 let answer = match opening.answer.try_recv() {
1392 Ok(answer) => answer,
1393 Err(TryRecvError::Empty) => {
1394 if let Some(line) = line {
1395 self.note_progress(format!("{line} (Ctrl+G aborts)"));
1396 }
1397 return;
1398 }
1399 Err(TryRecvError::Disconnected) => Err(anyhow::anyhow!("the connection is gone")),
1400 };
1401 let Some(opening) = self.opening.take() else {
1402 return;
1403 };
1404 if mem::take(&mut self.progress_noted) {
1405 self.clear_notice();
1406 }
1407 match answer {
1408 Ok((next, warnings)) => self.become_(next, warnings, opening.read_only),
1409 Err(err) => self.error(format!("cannot open {}: {err:#}", opening.spec)),
1410 }
1411 }
1412
1413 pub fn busy(&self) -> Option<&'static str> {
1415 if self.opening.is_some() {
1416 return Some("opening the folder");
1417 }
1418 self.imap.as_ref().and_then(Imap::busy)
1419 }
1420
1421 pub fn abort_network(&mut self) {
1425 let Some(what) = self.busy() else {
1426 return;
1427 };
1428 if self.opening.take().is_some() {
1429 self.note(format!("aborted: {what}"));
1432 self.progress_noted = false;
1433 return;
1434 }
1435 if let Some(imap) = &self.imap {
1436 imap.abort();
1437 }
1438 self.note(format!("aborted: {what}"));
1439 self.deferred.retain(Deferred::outlives_abort);
1442 self.listing = false;
1443 self.progress_noted = false;
1446 self.aborted = true;
1447 }
1448
1449 fn start(&mut self, job: Job, pending: Pending) -> bool {
1456 if self.imap.is_none() {
1457 return false;
1458 }
1459 if self.pending.is_some() {
1460 self.deferred.push_back(Deferred::Job(job, pending));
1461 return true;
1462 }
1463 let Some(imap) = &mut self.imap else {
1464 return false;
1465 };
1466 let what = job.what();
1467 match imap.start(job) {
1468 Ok(()) => {
1469 self.note_progress(format!("{what}... (Ctrl+G aborts)"));
1473 self.pending = Some(pending);
1474 true
1475 }
1476 Err(err) => {
1477 self.error(format!("imap: {err:#}"));
1478 false
1479 }
1480 }
1481 }
1482
1483 fn note_progress(&mut self, line: String) {
1487 if self.progress_noted || !self.spoken {
1488 self.note(line);
1489 self.spoken = false;
1490 self.progress_noted = true;
1491 }
1492 }
1493
1494 fn send_then(&mut self, job: Job, hold: bool, then: Then) {
1498 if self.imap.is_none() {
1499 then(self, Err(anyhow::anyhow!("not connected")));
1500 return;
1501 }
1502 self.start(job, Pending::Then { hold, then });
1505 }
1506
1507 fn settle(&mut self) {
1510 while let Some(pending) = self.pending.take() {
1511 let Some(imap) = &mut self.imap else { break };
1512 let done = imap.wait();
1513 if mem::take(&mut self.progress_noted) {
1514 self.clear_notice();
1515 }
1516 self.resume(pending, done);
1517 }
1518 }
1519
1520 fn resume(&mut self, pending: Pending, done: Result<Done>) {
1521 if mem::take(&mut self.aborted) && done.is_err() {
1522 return;
1525 }
1526 match pending {
1527 Pending::CheckNew => {
1528 if let Err(err) = done {
1529 self.error(format!("imap: {err:#}"));
1530 }
1531 self.check_local_mail();
1532 }
1533 Pending::Sync { purge } => match done {
1534 Err(err) => self.error(format!("sync failed: {err:#}")),
1536 Ok(_) => self.finish_sync(purge),
1537 },
1538 Pending::Counts(specs) => {
1539 if let Ok(Done::Counts(counts)) = done {
1540 for (spec, count) in specs.into_iter().zip(counts) {
1541 self.unseen.insert(spec, count);
1542 }
1543 self.requests.push(Request::MailboxesChanged);
1544 }
1545 }
1546 Pending::Again(again) => match done {
1547 Err(err) => self.error(format!("cannot fetch message: {err:#}")),
1548 Ok(_) => self.run_again(again),
1549 },
1550 Pending::Then { then, .. } => then(self, done),
1551 }
1552 }
1553
1554 fn run_again(&mut self, again: Again) {
1557 match again {
1558 Again::View => self.open_message(),
1559 Again::Copy {
1560 input,
1561 delete,
1562 tagged,
1563 decode,
1564 } => self.copy_message(&input, delete, tagged, decode),
1565 Again::Pipe { command, tagged } => self.pipe_message(&command, tagged),
1566 Again::Print { tagged } => self.print_current(tagged),
1567 Again::Bounce { to, tagged } => self.bounce_current(&to, tagged),
1568 }
1569 }
1570
1571 fn have_bodies(&mut self, paths: &[PathBuf], again: Again) -> bool {
1578 if self.imap.is_none() {
1579 return true;
1580 }
1581 let missing: Vec<PathBuf> = paths
1582 .iter()
1583 .filter(|path| remote::is_partial(path))
1584 .cloned()
1585 .collect();
1586 if missing.is_empty() {
1587 return true;
1588 }
1589 if self.pending.is_some() {
1593 self.deferred.push_back(Deferred::Again(again));
1594 return false;
1595 }
1596 !self.start(Job::FetchBodies(missing), Pending::Again(again))
1597 }
1598
1599 fn target_paths(&self, tagged: bool) -> Vec<PathBuf> {
1602 self.op_targets(tagged)
1603 .into_iter()
1604 .map(|i| self.msgs[i].env.file.path.clone())
1605 .collect()
1606 }
1607
1608 pub fn idle_kick(&self) -> bool {
1611 self.idle.as_ref().is_some_and(|w| w.take_changed())
1612 }
1613
1614 fn check_other_mailboxes(&mut self) {
1617 let mut grew: Vec<String> = Vec::new();
1618 for spec in self.config.mail.mailboxes.clone() {
1619 if spec.starts_with("imap:") || spec == self.title {
1620 continue; }
1622 let dir = expand_tilde(&spec);
1623 if dir == self.dir || !dir.join("new").is_dir() {
1624 continue;
1625 }
1626 let count = maildir::new_count(&dir);
1627 let prev = self.mailbox_new.insert(spec.clone(), count);
1628 if let Some(p) = prev.filter(|&p| count > p) {
1629 self.run_new_mail_command(&spec, count - p);
1630 grew.push(spec);
1631 } else if !self.config.mail.mail_check_recent.unwrap_or(true) {
1632 if count == 0 {
1636 self.mailbox_announced.remove(&spec);
1637 } else if self.mailbox_announced.insert(spec.clone()) {
1638 grew.push(spec);
1639 }
1640 }
1641 }
1642 if !grew.is_empty() && self.notice().is_none() {
1644 self.notify(Notice::NewMail(format!("new mail in {}", grew.join(", "))));
1645 }
1646 }
1647
1648 pub fn maybe_backfill(&mut self) {
1651 if self.backfill.as_ref().is_some_and(|b| !b.done()) {
1652 return;
1653 }
1654 self.backfill = None;
1655 let Some(imap) = &mut self.imap else {
1656 return;
1657 };
1658 if imap.facts.pending_backfill.is_empty() {
1659 return;
1660 }
1661 let uids = imap.take_backfill();
1662 let facts = imap.facts.clone();
1663 let Ok(password) = account_password(&facts.account) else {
1664 return;
1665 };
1666 let count = uids.len();
1667 self.backfill = Some(remote::backfill(
1668 &facts.account,
1669 &facts.mailbox,
1670 &password,
1671 facts.cache,
1672 uids,
1673 ));
1674 self.note(format!(
1675 "loading {count} older message(s) in the background"
1676 ));
1677 }
1678
1679 pub fn error_history(&self) -> Vec<String> {
1683 self.error_history.iter().cloned().collect()
1684 }
1685
1686 pub fn next_unread_mailbox(&self) -> Option<String> {
1691 let boxes = &self.config.mail.mailboxes;
1692 let start = boxes
1693 .iter()
1694 .position(|spec| *spec == self.title || expand_tilde(spec) == self.dir)
1695 .map(|i| i + 1)
1696 .unwrap_or(0);
1697 (0..boxes.len())
1698 .map(|k| &boxes[(start + k) % boxes.len()])
1699 .filter(|spec| **spec != self.title && expand_tilde(spec) != self.dir)
1700 .find(|spec| {
1701 if spec.starts_with("imap:") {
1702 self.unseen.get(*spec).copied().unwrap_or(0) > 0
1703 } else {
1704 maildir::new_count(&expand_tilde(spec)) > 0
1705 }
1706 })
1707 .cloned()
1708 }
1709
1710 pub fn delete_rules(&self) -> DeleteRules {
1713 DeleteRules {
1714 flag_safe: self.config.mail.flag_safe,
1715 untag: self.config.mail.delete_untag.unwrap_or(true),
1716 }
1717 }
1718
1719 pub fn each_tagged(&mut self, what: &str, f: impl Fn(&mut Msg)) {
1720 let tagged: Vec<usize> = (0..self.msgs.len())
1721 .filter(|&i| self.msgs[i].env.tagged)
1722 .collect();
1723 self.push_undo(what, &tagged);
1724 for &i in &tagged {
1725 let m = &mut self.msgs[i];
1726 f(m);
1727 m.dirty = true;
1728 }
1729 self.note(format!("applied to {} tagged message(s)", tagged.len()));
1730 }
1731
1732 pub fn op_targets(&self, tagged: bool) -> Vec<usize> {
1740 if tagged {
1741 return (0..self.msgs.len())
1742 .filter(|&i| self.msgs[i].env.tagged)
1743 .collect();
1744 }
1745 self.visible.get(self.sel).copied().into_iter().collect()
1746 }
1747
1748 pub fn push_undo(&mut self, what: &str, indices: &[usize]) {
1751 if indices.is_empty() {
1752 return;
1753 }
1754 let marks = indices.iter().map(|&i| self.mark(i)).collect();
1755 self.push_undo_step(UndoStep {
1756 what: what.to_string(),
1757 marks,
1758 sel: self.selected_path(),
1759 created: Vec::new(),
1760 note: None,
1761 rewritten: Vec::new(),
1762 });
1763 }
1764
1765 pub fn push_undo_step(&mut self, step: UndoStep) {
1766 self.undo.push(step);
1767 while self.undo.len() > UNDO_MAX_STEPS
1769 || (self.undo.len() > 1
1770 && self.undo.iter().map(|s| s.marks.len()).sum::<usize>() > UNDO_MAX_MARKS)
1771 {
1772 self.undo.remove(0);
1773 }
1774 }
1775
1776 pub fn mark(&self, i: usize) -> MsgMark {
1777 let m = &self.msgs[i];
1778 MsgMark {
1779 path: m.env.file.path.clone(),
1780 flags: m.env.file.flags,
1781 is_new: m.env.file.is_new,
1782 tagged: m.env.tagged,
1783 dirty: m.dirty,
1784 }
1785 }
1786
1787 pub fn undo_last(&mut self) {
1793 let Some(step) = self.undo.pop() else {
1794 self.note("nothing to undo");
1795 return;
1796 };
1797 let mut restored = 0usize;
1798 let mut rewrite_failed = Vec::new();
1799 let mut resort = false;
1800 for (path, bytes) in &step.rewritten {
1801 let at = self.msgs.iter().position(|m| &m.env.file.path == path);
1802 let put_back = std::fs::write(path, bytes)
1803 .map_err(|err| anyhow::anyhow!(err))
1804 .and_then(|()| match at {
1805 Some(i) => self.reread(i, bytes.len() as u64),
1806 None => Ok(()),
1807 });
1808 match put_back {
1809 Ok(()) => resort = true,
1810 Err(err) => rewrite_failed.push(format!("{}: {err}", path.display())),
1811 }
1812 }
1813 if resort {
1814 let keep = step.sel.clone();
1815 self.resort(keep);
1816 }
1817 let by_path: HashMap<&Path, usize> = self
1818 .msgs
1819 .iter()
1820 .enumerate()
1821 .map(|(i, m)| (m.env.file.path.as_path(), i))
1822 .collect();
1823 let mut wanted: Vec<(usize, MsgMark)> = Vec::new();
1824 for mark in &step.marks {
1825 if let Some(&i) = by_path.get(mark.path.as_path()) {
1826 wanted.push((i, mark.clone()));
1827 }
1828 }
1829 for (i, mark) in wanted {
1830 let m = &mut self.msgs[i];
1831 m.env.file.flags = mark.flags;
1832 m.env.file.is_new = mark.is_new;
1833 m.env.tagged = mark.tagged;
1834 m.dirty = mark.dirty;
1835 restored += 1;
1836 }
1837 let mut failed = rewrite_failed;
1838 for path in &step.created {
1839 if let Err(err) = std::fs::remove_file(path) {
1840 failed.push(format!("{}: {err}", path.display()));
1841 }
1842 }
1843 if let Some(vi) = step.sel.and_then(|p| {
1844 self.visible
1845 .iter()
1846 .position(|&i| self.msgs[i].env.file.path == p)
1847 }) {
1848 self.sel = vi;
1849 }
1850 let mut note = format!("undone: {} ({restored} message(s))", step.what);
1851 if let Some(extra) = &step.note {
1852 note += &format!("; {extra}");
1853 }
1854 if !failed.is_empty() {
1855 self.error(format!("{note}; could not remove {}", failed.join("; ")));
1856 } else {
1857 self.note(note);
1858 }
1859 }
1860
1861 pub fn message_bytes(&mut self, i: usize) -> Option<Vec<u8>> {
1864 self.settle();
1868 let path = self.msgs.get(i)?.env.file.path.clone();
1869 if remote::is_partial(&path)
1870 && let Some(imap) = &mut self.imap
1871 && let Err(err) = imap.blocking(Job::FetchBodies(vec![path.clone()]))
1872 {
1873 self.error(format!("cannot fetch message: {err:#}"));
1874 return None;
1875 }
1876 match std::fs::read(&path) {
1877 Ok(bytes) => Some(bytes),
1878 Err(err) => {
1879 self.error(format!("cannot read message: {err}"));
1880 None
1881 }
1882 }
1883 }
1884
1885 pub fn full_message_bytes(&mut self) -> Option<Vec<u8>> {
1886 let &i = self.visible.get(self.sel)?;
1887 self.message_bytes(i)
1888 }
1889
1890 pub fn op_bytes(&mut self, tagged: bool) -> Option<Vec<u8>> {
1892 let mut out = Vec::new();
1893 for i in self.op_targets(tagged) {
1894 let bytes = self.message_bytes(i)?;
1895 out.extend_from_slice(&bytes);
1896 if !bytes.ends_with(b"\n") {
1897 out.push(b'\n');
1898 }
1899 }
1900 Some(out)
1901 }
1902
1903 pub fn rebuild_visible(&mut self, keep: Option<PathBuf>) {
1906 let mut visible = Vec::with_capacity(self.msgs.len());
1907 let positions = self.positions();
1908 for (i, pos) in positions.iter().enumerate() {
1909 let limit_ok = match &self.limit {
1910 Some((_, patterns)) => self.env_matches_at(patterns, &self.msgs[i].env, *pos, i),
1911 None => true,
1912 };
1913 if !limit_ok {
1914 continue;
1915 }
1916 if self.limit.is_none()
1917 && self.sort == SortKey::Threads
1918 && self.thread_depth.get(i).copied().unwrap_or(0) > 0
1919 {
1920 let root = self.thread_root.get(i).copied().unwrap_or(i);
1921 if self.collapsed.contains(&self.msgs[root].env.file.path) {
1922 continue;
1923 }
1924 }
1925 visible.push(i);
1926 }
1927 self.visible = visible;
1928 self.sel = keep
1929 .and_then(|p| {
1930 self.visible
1931 .iter()
1932 .position(|&i| self.msgs[i].env.file.path == p)
1933 })
1934 .unwrap_or_else(|| self.visible.len().saturating_sub(1));
1935 }
1936
1937 pub fn apply_sort(&mut self) {
1938 let keep = self.selected_path();
1939 self.resort(keep);
1940 }
1941
1942 fn resort(&mut self, keep: Option<PathBuf>) {
1943 if self.sort == SortKey::Threads {
1944 let order = self
1947 .config
1948 .index
1949 .sort_aux
1950 .as_deref()
1951 .map(thread::ThreadOrder::parse)
1952 .unwrap_or_default();
1953 let items = {
1958 let fallback = (!self.config.index.strict_threads.unwrap_or(false)).then(|| {
1959 thread::SubjectFallback {
1960 reply_re: &self.reply_re,
1961 sort_re: self.config.index.sort_re.unwrap_or(true),
1962 }
1963 });
1964 let envs: Vec<&Envelope> = self.msgs.iter().map(|m| &m.env).collect();
1965 thread::thread_with(&envs, order, fallback.as_ref())
1966 };
1967 let mut old: Vec<Option<Msg>> = self.msgs.drain(..).map(Some).collect();
1968 let mut new_pos = vec![0usize; old.len()];
1969 for (pos, item) in items.iter().enumerate() {
1970 new_pos[item.index] = pos;
1971 }
1972 self.msgs = items
1973 .iter()
1974 .map(|item| {
1975 old[item.index]
1976 .take()
1977 .expect("thread order is a permutation")
1978 })
1979 .collect();
1980 self.thread_depth = items.iter().map(|item| item.depth).collect();
1981 self.thread_root = items.iter().map(|item| new_pos[item.root]).collect();
1982 self.thread_pseudo = items.iter().map(|item| item.pseudo).collect();
1983 self.sort_rev = false;
1984 self.index_threads();
1985 } else {
1986 let (sort, rev) = (self.sort, self.sort_rev);
1987 self.msgs.sort_by(|a, b| {
1988 let ord = match sort {
1989 SortKey::Date => a.env.date.cmp(&b.env.date),
1990 SortKey::From => a.env.from.to_lowercase().cmp(&b.env.from.to_lowercase()),
1991 SortKey::Subject => {
1992 subject_key(&a.env.subject).cmp(&subject_key(&b.env.subject))
1993 }
1994 SortKey::Size => a.env.file.size.cmp(&b.env.file.size),
1995 SortKey::Label => {
1996 let key = |e: &Envelope| {
1998 let l = e.label.clone().unwrap_or_default().to_lowercase();
1999 (l.is_empty(), l)
2000 };
2001 key(&a.env).cmp(&key(&b.env))
2002 }
2003 SortKey::To => {
2004 let first_to =
2005 |e: &Envelope| e.to.first().cloned().unwrap_or_default().to_lowercase();
2006 first_to(&a.env).cmp(&first_to(&b.env))
2007 }
2008 SortKey::Unsorted => a
2009 .env
2010 .file
2011 .path
2012 .file_name()
2013 .cmp(&b.env.file.path.file_name()),
2014 SortKey::Threads => unreachable!(),
2015 };
2016 if rev { ord.reverse() } else { ord }
2017 });
2018 self.thread_depth = vec![0; self.msgs.len()];
2019 self.thread_root = (0..self.msgs.len()).collect();
2020 self.thread_pseudo = vec![false; self.msgs.len()];
2021 self.index_threads();
2022 }
2023 self.rebuild_visible(keep);
2024 }
2025
2026 fn index_threads(&mut self) {
2029 let n = self.msgs.len();
2030 let mut parent = vec![None; n];
2031 let mut children = vec![Vec::new(); n];
2032 let mut members: Vec<Vec<usize>> = vec![Vec::new(); n];
2033 let mut last_at_depth: Vec<usize> = Vec::new();
2034 #[allow(clippy::needless_range_loop)]
2035 for i in 0..n {
2036 let depth = self.thread_depth.get(i).copied().unwrap_or(0);
2037 last_at_depth.truncate(depth);
2038 if let Some(&p) = last_at_depth.last() {
2039 parent[i] = Some(p);
2040 children[p].push(i);
2041 }
2042 last_at_depth.push(i);
2043 let root = self.thread_root.get(i).copied().unwrap_or(i);
2044 members[root].push(i);
2045 }
2046 self.thread_parent = parent;
2047 self.thread_children = children;
2048 self.thread_members = members;
2049 }
2050
2051 pub fn toggle_collapse(&mut self, all: bool) {
2052 if self.sort != SortKey::Threads {
2053 self.error("folding needs thread sort (o t)");
2054 return;
2055 }
2056 let mut keep;
2057 if all {
2058 keep = self.selected_path();
2059 if self.collapsed.is_empty() {
2060 let fold_unread = self.config.index.collapse_unread.unwrap_or(true);
2063 self.collapsed = (0..self.msgs.len())
2064 .filter(|&i| self.thread_depth.get(i) == Some(&0))
2065 .filter(|&i| fold_unread || !self.thread_has_unread(i))
2066 .map(|i| self.msgs[i].env.file.path.clone())
2067 .collect();
2068 } else {
2069 self.collapsed.clear();
2070 }
2071 } else {
2072 let Some(&mi) = self.visible.get(self.sel) else {
2073 return;
2074 };
2075 let root = self.thread_root.get(mi).copied().unwrap_or(mi);
2076 let path = self.msgs[root].env.file.path.clone();
2077 let unfolding = self.collapsed.remove(&path);
2078 if !unfolding {
2079 self.collapsed.insert(path.clone());
2080 }
2081 keep = Some(path);
2082 if unfolding && self.config.index.uncollapse_jump {
2084 keep = self.first_unread_in_thread(root).or(keep);
2085 }
2086 }
2087 self.rebuild_visible(keep);
2088 }
2089
2090 pub fn step_message(&self, forward: bool, skip_deleted: bool) -> Option<usize> {
2093 let mut pos = self.sel;
2094 loop {
2095 pos = if forward {
2096 pos + 1
2097 } else {
2098 pos.checked_sub(1)?
2099 };
2100 let &i = self.visible.get(pos)?;
2101 if !skip_deleted || !self.msgs[i].env.file.flags.deleted {
2102 return Some(pos);
2103 }
2104 }
2105 }
2106
2107 fn thread_targets(&mut self, sub: bool) -> Option<Vec<usize>> {
2115 if self.sort != SortKey::Threads {
2116 self.error("thread operations need thread sort (o t)");
2117 return None;
2118 }
2119 let &mi = self.visible.get(self.sel)?;
2120 if !sub {
2121 let root = self.thread_root.get(mi).copied().unwrap_or(mi);
2122 return Some(
2123 (0..self.msgs.len())
2124 .filter(|&i| self.thread_root.get(i).copied().unwrap_or(i) == root)
2125 .collect(),
2126 );
2127 }
2128 let depth = self.thread_depth.get(mi).copied().unwrap_or(0);
2131 let mut out = vec![mi];
2132 for i in (mi + 1)..self.msgs.len() {
2133 if self.thread_depth.get(i).copied().unwrap_or(0) <= depth {
2134 break;
2135 }
2136 out.push(i);
2137 }
2138 Some(out)
2139 }
2140
2141 pub fn thread_mark(&mut self, sub: bool, op: ThreadOp) {
2145 if op != ThreadOp::Tag && self.deny_readonly() {
2146 return;
2147 }
2148 let Some(&mi) = self.visible.get(self.sel) else {
2149 return;
2150 };
2151 let Some(targets) = self.thread_targets(sub) else {
2152 return;
2153 };
2154 let scope = if sub { "subthread" } else { "thread" };
2155 let (what, verb) = match op {
2156 ThreadOp::Delete => (format!("delete {scope}"), "deleted"),
2157 ThreadOp::Undelete => (format!("undelete {scope}"), "undeleted"),
2158 ThreadOp::Read => (format!("read {scope}"), "marked read"),
2159 ThreadOp::Tag if self.msgs[mi].env.tagged => (format!("untag {scope}"), "untagged"),
2162 ThreadOp::Tag => (format!("tag {scope}"), "tagged"),
2163 };
2164 self.push_undo(&what, &targets);
2165 let want_tag = !self.msgs[mi].env.tagged;
2166 let rules = self.delete_rules();
2167 for &i in &targets {
2168 match op {
2169 ThreadOp::Delete => {
2170 if rules.mark(&mut self.msgs[i]) {
2171 self.msgs[i].dirty = true;
2172 }
2173 }
2174 ThreadOp::Undelete => {
2175 self.msgs[i].env.file.flags.deleted = false;
2176 self.msgs[i].dirty = true;
2177 }
2178 ThreadOp::Tag => self.msgs[i].env.tagged = want_tag,
2179 ThreadOp::Read => {
2180 let m = &mut self.msgs[i];
2181 if !m.env.file.flags.seen || m.env.file.is_new {
2182 m.env.file.flags.seen = true;
2183 m.env.file.is_new = false;
2184 m.dirty = true;
2185 }
2186 }
2187 }
2188 }
2189 self.note(format!("{} {verb}", targets.len()));
2190 if op == ThreadOp::Delete
2193 && let Some(pos) = self.step_message(true, true)
2194 {
2195 self.sel = pos;
2196 }
2197 }
2198
2199 pub fn jump_parent(&mut self, root: bool) {
2203 if self.sort != SortKey::Threads {
2204 self.error("thread operations need thread sort (o t)");
2205 return;
2206 }
2207 let Some(&mi) = self.visible.get(self.sel) else {
2208 return;
2209 };
2210 let target = if root {
2211 self.thread_root.get(mi).copied().filter(|&r| r != mi)
2212 } else {
2213 self.thread_parent.get(mi).copied().flatten()
2214 };
2215 let Some(target) = target else {
2216 self.error(if root {
2217 "already the thread root"
2218 } else {
2219 "no parent message"
2220 });
2221 return;
2222 };
2223 let tr = self.thread_root.get(target).copied().unwrap_or(target);
2224 let at = self
2225 .visible
2226 .iter()
2227 .position(|&i| i == target)
2228 .or_else(|| self.visible.iter().position(|&i| i == tr));
2229 match at {
2230 Some(vi) => self.select(vi),
2231 None => self.error("the parent is not in the current limit"),
2232 }
2233 }
2234
2235 fn can_rewrite(&mut self) -> bool {
2240 if self.deny_readonly() {
2241 return false;
2242 }
2243 if self.sort != SortKey::Threads {
2244 self.error("thread operations need thread sort (o t)");
2245 return false;
2246 }
2247 if self.imap.is_some() || self.mbox.is_some() {
2248 self.error("rewriting a message's threading is for local maildirs only");
2249 return false;
2250 }
2251 true
2252 }
2253
2254 fn rewrite_thread(
2260 &mut self,
2261 i: usize,
2262 in_reply_to: Option<&str>,
2263 references: &[String],
2264 broken: bool,
2265 ) -> Result<Vec<u8>> {
2266 let path = self.msgs[i].env.file.path.clone();
2267 let old = std::fs::read(&path).with_context(|| format!("reading {}", path.display()))?;
2268 let new = message::with_thread_headers(&old, in_reply_to, references, broken);
2269 std::fs::write(&path, &new).with_context(|| format!("writing {}", path.display()))?;
2270 self.reread(i, new.len() as u64)?;
2271 Ok(old)
2272 }
2273
2274 fn reread(&mut self, i: usize, size: u64) -> Result<()> {
2277 let mut file = self.msgs[i].env.file.clone();
2278 file.size = size;
2279 let tagged = self.msgs[i].env.tagged;
2280 let mut env = message::envelope(file)?;
2281 env.tagged = tagged;
2282 self.msgs[i].env = env;
2283 Ok(())
2284 }
2285
2286 pub fn break_thread(&mut self) {
2291 let Some(&mi) = self.visible.get(self.sel) else {
2292 return;
2293 };
2294 if !self.can_rewrite() {
2295 return;
2296 }
2297 if self.msgs[mi].env.references.is_empty() && !self.subject_threaded(mi) {
2301 self.note("already a thread of its own");
2302 return;
2303 }
2304 let path = self.msgs[mi].env.file.path.clone();
2305 let mut step = UndoStep {
2306 what: "break thread".into(),
2307 marks: vec![self.mark(mi)],
2308 sel: Some(path.clone()),
2309 created: Vec::new(),
2310 note: None,
2311 rewritten: Vec::new(),
2312 };
2313 match self.rewrite_thread(mi, None, &[], true) {
2314 Ok(old) => {
2315 step.rewritten.push((path.clone(), old));
2316 self.push_undo_step(step);
2317 self.resort(Some(path));
2318 self.note("thread broken");
2319 }
2320 Err(err) => self.error(format!("break thread: {err:#}")),
2321 }
2322 }
2323
2324 pub fn link_threads(&mut self) {
2329 let Some(&mi) = self.visible.get(self.sel) else {
2330 return;
2331 };
2332 if !self.can_rewrite() {
2333 return;
2334 }
2335 let Some(parent_id) = self.msgs[mi].env.msg_id.clone() else {
2336 self.error("no Message-ID to link to");
2337 return;
2338 };
2339 let kids: Vec<usize> = (0..self.msgs.len())
2340 .filter(|&i| i != mi && self.msgs[i].env.tagged)
2341 .collect();
2342 if kids.is_empty() {
2343 self.error("first tag the message(s) to link");
2344 return;
2345 }
2346 let keep = self.msgs[mi].env.file.path.clone();
2347 let mut step = UndoStep {
2348 what: "link threads".into(),
2349 marks: kids.iter().map(|&i| self.mark(i)).collect(),
2350 sel: Some(keep.clone()),
2351 created: Vec::new(),
2352 note: None,
2353 rewritten: Vec::new(),
2354 };
2355 let mut linked = 0usize;
2356 for &i in &kids {
2357 let path = self.msgs[i].env.file.path.clone();
2358 match self.rewrite_thread(i, Some(&parent_id), &[], false) {
2359 Ok(old) => {
2360 step.rewritten.push((path, old));
2361 self.msgs[i].env.tagged = false;
2362 linked += 1;
2363 }
2364 Err(err) => self.error(format!("link threads: {err:#}")),
2365 }
2366 }
2367 if linked > 0 {
2368 self.push_undo_step(step);
2369 self.resort(Some(keep));
2370 self.note(format!("{linked} linked"));
2371 }
2372 }
2373
2374 pub fn edit_label(&mut self, value: &str, tagged: bool) {
2379 if !self.can_rewrite_here() {
2380 return;
2381 }
2382 let targets = self.op_targets(tagged);
2383 if targets.is_empty() {
2384 return;
2385 }
2386 let value = value.trim();
2387 let new = (!value.is_empty()).then(|| value.to_string());
2388 let keep = self.selected_path();
2389 let mut step = UndoStep {
2390 what: "edit label".into(),
2391 marks: targets.iter().map(|&i| self.mark(i)).collect(),
2392 sel: keep.clone(),
2393 created: Vec::new(),
2394 note: None,
2395 rewritten: Vec::new(),
2396 };
2397 let mut done = 0usize;
2398 for &i in &targets {
2399 let path = self.msgs[i].env.file.path.clone();
2400 let old = match std::fs::read(&path) {
2401 Ok(b) => b,
2402 Err(err) => {
2403 self.error(format!("edit label: {err}"));
2404 continue;
2405 }
2406 };
2407 let bytes = message::with_header(&old, "X-Label", new.as_deref());
2408 if let Err(err) = std::fs::write(&path, &bytes) {
2409 self.error(format!("edit label: {err}"));
2410 continue;
2411 }
2412 step.rewritten.push((path, old));
2413 let _ = self.reread(i, bytes.len() as u64);
2414 done += 1;
2415 }
2416 if done > 0 {
2417 self.push_undo_step(step);
2418 self.resort(keep);
2419 self.note(match new {
2420 Some(_) => format!("labelled {done} message(s)"),
2421 None => format!("label cleared on {done} message(s)"),
2422 });
2423 }
2424 }
2425
2426 fn can_rewrite_here(&mut self) -> bool {
2429 if self.deny_readonly() {
2430 return false;
2431 }
2432 if self.imap.is_some() || self.mbox.is_some() {
2433 self.error("editing a label is for local maildirs only");
2434 return false;
2435 }
2436 true
2437 }
2438
2439 pub fn subject_threaded(&self, mi: usize) -> bool {
2443 self.thread_pseudo.get(mi).copied().unwrap_or(false)
2444 }
2445
2446 pub fn subject_hidden(&self, mi: usize) -> bool {
2451 if !self.config.index.hide_thread_subject.unwrap_or(false) {
2452 return false;
2453 }
2454 let Some(parent) = self.thread_parent.get(mi).copied().flatten() else {
2455 return false;
2456 };
2457 match (self.msgs.get(mi), self.msgs.get(parent)) {
2458 (Some(m), Some(p)) => subject_key(&m.env.subject) == subject_key(&p.env.subject),
2459 _ => false,
2460 }
2461 }
2462
2463 pub fn toggle_write(&mut self) {
2467 if self.read_only_session {
2468 self.error("mailbox is read-only for the whole session (-R)");
2469 return;
2470 }
2471 self.read_only = !self.read_only;
2472 if self.read_only {
2473 self.note("mailbox marked read-only");
2474 } else {
2475 self.note("mailbox marked writable");
2476 }
2477 }
2478
2479 pub fn show_limit(&mut self) {
2482 match &self.limit {
2483 Some((raw, _)) => self.note(format!("limit: {raw}")),
2484 None => self.note("no limit pattern (all messages shown)"),
2485 }
2486 }
2487
2488 pub fn reply_subject(&self, orig: &str) -> String {
2490 compose::reply_subject(orig, &self.reply_re)
2491 }
2492
2493 pub fn jump_thread(&mut self, forward: bool) {
2496 if self.sort != SortKey::Threads {
2497 self.error("thread operations need thread sort (o t)");
2498 return;
2499 }
2500 for (vi, wrapped) in wrap_order(self.visible.len(), self.sel, forward) {
2501 let mi = self.visible[vi];
2502 if self.thread_depth.get(mi).copied().unwrap_or(0) == 0 {
2503 if wrapped {
2504 self.note("wrapped around");
2505 }
2506 self.select(vi);
2507 return;
2508 }
2509 }
2510 self.error("no other thread");
2511 }
2512
2513 pub fn jump_new(&mut self, forward: bool) {
2514 let n = self.visible.len();
2515 if n == 0 {
2516 return;
2517 }
2518 for (vi, wrapped) in wrap_order(n, self.sel, forward) {
2519 let m = &self.msgs[self.visible[vi]];
2520 if m.env.file.is_new || !m.env.file.flags.seen {
2521 if wrapped {
2522 self.note("search wrapped");
2523 }
2524 self.select(vi);
2525 return;
2526 }
2527 }
2528 self.error("no new or unread messages");
2529 }
2530
2531 pub fn compile_search(&self, input: &str) -> Result<Vec<Pattern>, String> {
2539 pattern::parse(&self.expand_simple(input))
2540 }
2541
2542 fn expand_simple(&self, input: &str) -> String {
2543 let word = input.trim();
2544 if word.contains('~') || word.split_whitespace().count() != 1 {
2545 return input.to_string();
2546 }
2547 let template = self
2548 .config
2549 .mail
2550 .simple_search
2551 .as_deref()
2552 .unwrap_or("~f %s | ~s %s");
2553 template.replace("%s", word)
2554 }
2555
2556 pub fn apply_pattern(&mut self, input: &str, op: PatternOp) {
2557 let flag_safe = self.config.mail.flag_safe;
2558 let verb = op.verb();
2559 if input.is_empty() {
2560 return;
2561 }
2562 let patterns = match self.compile_search(input) {
2563 Ok(p) => p,
2564 Err(err) => {
2565 self.error(format!("bad pattern: {err}"));
2566 return;
2567 }
2568 };
2569 let redo_input = input.to_string();
2570 if !self.body_terms_ready(
2571 &patterns,
2572 Box::new(move |session| session.apply_pattern(&redo_input, op)),
2573 ) {
2574 return;
2575 }
2576 let positions = self.positions();
2577 let mut hits = Vec::new();
2578 for (i, pos) in positions.iter().enumerate() {
2579 let in_limit = match &self.limit {
2580 Some((_, l)) => self.env_matches_at(l, &self.msgs[i].env, *pos, i),
2581 None => true,
2582 };
2583 if in_limit && self.env_matches_at(&patterns, &self.msgs[i].env, *pos, i) {
2584 hits.push(i);
2585 }
2586 }
2587 self.push_undo(&format!("{verb} by pattern"), &hits);
2588 for &i in &hits {
2589 op.apply(&mut self.msgs[i], flag_safe);
2590 }
2591 self.note(format!("{} {verb}", hits.len()));
2592 }
2593
2594 pub fn search_next(&mut self) {
2595 let Some(patterns) = self.last_search.clone() else {
2596 self.error("no search pattern (use /)");
2597 return;
2598 };
2599 if self.visible.is_empty() {
2600 return;
2601 }
2602 let wrap = self.config.mail.wrap_search.unwrap_or(true);
2605 let positions = self.positions();
2606 for (vi, wrapped) in wrap_order(self.visible.len(), self.sel, !self.search_rev) {
2607 if wrapped && !wrap {
2608 break;
2609 }
2610 let mi = self.visible[vi];
2611 if self.env_matches_at(&patterns, &self.msgs[mi].env, positions[mi], mi) {
2612 if wrapped {
2613 self.note("search wrapped");
2614 }
2615 self.sel = vi;
2616 return;
2617 }
2618 }
2619 self.error("not found");
2620 }
2621
2622 fn trash_deleted(&mut self, trash: &str) -> Result<()> {
2626 self.settle();
2630 let deleted: Vec<PathBuf> = self
2632 .msgs
2633 .iter()
2634 .filter(|m| m.env.file.flags.deleted && !m.purge)
2635 .map(|m| m.env.file.path.clone())
2636 .collect();
2637 match (remote::parse_spec(trash), &mut self.imap) {
2638 (Some((account, folder)), Some(imap)) if imap.facts.account.name == account => imap
2639 .blocking(Job::CopyToFolder {
2640 paths: deleted,
2641 mailbox: folder.to_string(),
2642 })
2643 .map(|_| ()),
2644 (Some(_), _) => anyhow::bail!("trash must be a folder of the open account"),
2645 (None, Some(_)) => {
2646 anyhow::bail!("an IMAP mailbox needs an imap:account/folder trash")
2647 }
2648 (None, None) => {
2649 let dir = expand_tilde(trash);
2650 maildir::create(&dir)?;
2651 for m in self
2652 .msgs
2653 .iter()
2654 .filter(|m| m.env.file.flags.deleted && !m.purge)
2655 {
2656 let bytes = std::fs::read(&m.env.file.path)?;
2657 let mut flags = m.env.file.flags;
2658 flags.deleted = false;
2659 maildir::deliver(&dir, &bytes, flags)?;
2660 }
2661 Ok(())
2662 }
2663 }
2664 }
2665
2666 pub fn sync(&mut self, purge: bool) {
2669 if self.deny_readonly() {
2670 return;
2671 }
2672 if purge
2675 && self.deleted_count() > 0
2676 && let Some(trash) = self.config.mail.trash.clone()
2677 && trash != self.title
2678 && expand_tilde(&trash) != self.dir
2679 && let Err(err) = self.trash_deleted(&trash)
2680 {
2681 self.error(format!("trash failed: {err:#}; nothing purged"));
2682 return;
2683 }
2684 if self.imap.is_some() {
2685 let mut deletes: Vec<PathBuf> = Vec::new();
2686 let mut flags: Vec<(PathBuf, maildir::Flags)> = Vec::new();
2687 for m in &self.msgs {
2688 if m.env.file.flags.deleted {
2689 if purge {
2690 deletes.push(m.env.file.path.clone());
2691 }
2692 } else if m.dirty {
2693 flags.push((m.env.file.path.clone(), m.env.file.flags));
2694 }
2695 }
2696 if self.start(Job::Sync { flags, deletes }, Pending::Sync { purge }) {
2700 return;
2701 }
2702 }
2703 self.finish_sync(purge);
2704 }
2705
2706 fn finish_sync(&mut self, purge: bool) {
2709 if let Some(mbox) = &mut self.mbox {
2710 let mut state: HashMap<String, Option<(maildir::Flags, bool)>> = HashMap::new();
2713 for m in &self.msgs {
2714 let Some(id) = mbox::id_of(&m.env.file.path) else {
2715 continue;
2716 };
2717 if m.env.file.flags.deleted && purge {
2718 state.insert(id, None);
2719 } else if m.pending() {
2720 let is_new = m.env.file.is_new && !m.dirty;
2721 state.insert(id, Some((m.env.file.flags, is_new)));
2722 }
2723 }
2724 if let Err(err) = mbox.write_back(&state) {
2725 self.error(format!("sync failed: {err:#}"));
2727 return;
2728 }
2729 }
2730 let keep = self.selected_path();
2731 let mut removed = 0usize;
2732 let mut saved = 0usize;
2733 let mut errors: Vec<String> = Vec::new();
2734 let trash = self.config.mail.maildir_trash && self.imap.is_none() && self.mbox.is_none();
2738 self.msgs.retain_mut(|m| {
2739 if m.env.file.flags.deleted {
2740 if !purge {
2741 return true; }
2743 if trash {
2744 if m.dirty {
2745 match maildir::store_flags(&m.env.file) {
2746 Ok(path) => {
2747 m.env.file.path = path;
2748 m.env.file.is_new = false;
2749 m.dirty = false;
2750 saved += 1;
2751 }
2752 Err(err) => errors.push(err.to_string()),
2753 }
2754 }
2755 return true;
2756 }
2757 match maildir::remove(&m.env.file) {
2758 Ok(()) => {
2759 removed += 1;
2760 false
2761 }
2762 Err(err) => {
2763 errors.push(err.to_string());
2764 true
2765 }
2766 }
2767 } else {
2768 if m.dirty {
2769 match maildir::store_flags(&m.env.file) {
2770 Ok(path) => {
2771 m.env.file.path = path;
2772 m.env.file.is_new = false;
2773 m.dirty = false;
2774 saved += 1;
2775 }
2776 Err(err) => errors.push(err.to_string()),
2777 }
2778 }
2779 true
2780 }
2781 });
2782 self.dir_mtimes = dir_mtimes(&self.dir);
2783 self.undo.clear();
2786 self.resort(keep);
2787 match errors.is_empty() {
2788 true => self.notify(Notice::Synced {
2789 deleted: removed,
2790 updated: saved,
2791 }),
2792 false => self.note(format!("sync errors: {}", errors.join("; "))),
2793 }
2794 }
2795
2796 pub fn create_folder(&mut self, spec: &str) -> Result<String, String> {
2807 let id = self.manage(
2808 spec,
2809 |_account, folder| Manage::Create(folder),
2810 |path| {
2811 maildir::create(&path).map_err(|e| format!("{e:#}"))?;
2812 Ok(path.display().to_string())
2813 },
2814 )?;
2815 Ok(format!("created {id}"))
2816 }
2817
2818 pub fn delete_folder(&mut self, spec: &str) -> Result<String, String> {
2819 let id = self.manage(
2820 spec,
2821 |_account, folder| Manage::Delete(folder),
2822 |path| {
2823 std::fs::remove_dir_all(&path).map_err(|e| format!("{e}"))?;
2826 Ok(path.display().to_string())
2827 },
2828 )?;
2829 Ok(format!("deleted {id}"))
2830 }
2831
2832 pub fn set_subscribed(&mut self, spec: &str, on: bool) -> Result<String, String> {
2833 let verb = if on {
2834 "subscribed to"
2835 } else {
2836 "unsubscribed from"
2837 };
2838 self.manage(
2839 spec,
2840 move |_account, folder| Manage::Subscribe(folder, on),
2841 |_path| Err("subscription is an IMAP notion".into()),
2842 )
2843 .map(|shown| format!("{verb} {shown}"))
2844 }
2845
2846 pub fn rename_folder(&mut self, spec: &str, new: &str) -> Result<String, String> {
2849 if new.trim().is_empty() {
2850 return Err("no new name given".into());
2851 }
2852 match remote::parse_spec(spec) {
2853 Some((account, from)) => {
2854 let to = new.trim().to_string();
2855 self.on_account(account, Manage::Rename(from.to_string(), to.clone()))?;
2856 Ok(format!("renamed to {to}"))
2857 }
2858 None => {
2859 let from = expand_tilde(spec);
2860 let to = expand_tilde(new);
2861 std::fs::rename(&from, &to).map_err(|e| format!("{e}"))?;
2862 Ok(format!("renamed to {}", to.display()))
2863 }
2864 }
2865 }
2866
2867 fn manage(
2871 &mut self,
2872 spec: &str,
2873 make: impl FnOnce(&str, String) -> Manage,
2874 local: impl FnOnce(std::path::PathBuf) -> Result<String, String>,
2875 ) -> Result<String, String> {
2876 match remote::parse_spec(spec) {
2877 Some((account, folder)) => {
2878 let action = make(account, folder.to_string());
2879 self.on_account(account, action)?;
2880 Ok(format!("imap:{account}/{folder}"))
2881 }
2882 None => local(expand_tilde(spec)),
2883 }
2884 }
2885
2886 fn on_account(&mut self, account: &str, action: Manage) -> Result<(), String> {
2889 self.settle();
2893 self.server_folders = None;
2896 match &mut self.imap {
2897 Some(imap) if imap.facts.account.name == account => imap
2898 .blocking(Job::Manage(action))
2899 .map(drop)
2900 .map_err(|e| format!("{e:#}")),
2901 Some(_) | None => Err("that mailbox is not on the open account".into()),
2902 }
2903 }
2904
2905 pub fn folder_candidates(&mut self) -> Vec<(String, usize)> {
2913 let mut dirs: Vec<(String, usize)> = self
2916 .config
2917 .mail
2918 .mailboxes
2919 .iter()
2920 .filter(|m| m.starts_with("imap:") || expand_tilde(m).join("cur").is_dir())
2921 .map(|m| {
2922 let count = if m.starts_with("imap:") {
2923 0
2924 } else {
2925 maildir::new_count(&expand_tilde(m))
2926 };
2927 (m.clone(), count)
2928 })
2929 .collect();
2930 match &self.imap {
2931 Some(imap) => {
2932 let account = imap.facts.account.name.clone();
2933 match &self.server_folders {
2934 Some(folders) => dirs.extend(
2935 folders
2936 .iter()
2937 .map(|(f, unseen)| (format!("imap:{account}/{f}"), *unseen)),
2938 ),
2939 None => self.refresh_folders(),
2940 }
2941 }
2942 None => dirs.extend(
2943 maildir::discover(&self.dir)
2944 .iter()
2945 .map(|p| (p.display().to_string(), maildir::new_count(p))),
2946 ),
2947 }
2948 let mut seen: HashMap<String, usize> = HashMap::new();
2951 let mut unique: Vec<(String, usize)> = Vec::new();
2952 for (name, count) in dirs {
2953 match seen.get(&name) {
2954 Some(&at) => unique[at].1 = unique[at].1.max(count),
2955 None => {
2956 seen.insert(name.clone(), unique.len());
2957 unique.push((name, count));
2958 }
2959 }
2960 }
2961 let mut dirs = unique;
2962 sort_browser(&mut dirs, self.config.ui.sort_browser.as_deref());
2963 dirs.dedup_by(|a, b| {
2966 if a.0 == b.0 {
2967 b.1 = b.1.max(a.1);
2968 true
2969 } else {
2970 false
2971 }
2972 });
2973 dirs
2974 }
2975
2976 pub fn refresh_folders(&mut self) {
2980 if self.imap.is_none() || self.listing {
2981 return;
2982 }
2983 self.listing = true;
2984 self.send_then(
2985 Job::Folders,
2986 false,
2987 Box::new(|session, done| {
2988 session.listing = false;
2989 match done {
2990 Ok(Done::Folders(folders)) => {
2991 session.server_folders = Some(folders);
2992 session.requests.push(Request::FoldersChanged);
2993 }
2994 Ok(_) => {}
2995 Err(err) => session.error(format!("imap: {err:#}")),
2996 }
2997 }),
2998 );
2999 }
3000
3001 pub fn unseen_count(&self, spec: &str) -> usize {
3010 match remote::parse_spec(spec) {
3011 Some(_) => self.unseen.get(spec).copied().unwrap_or(0),
3012 None => maildir::new_count(&expand_tilde(spec)),
3013 }
3014 }
3015
3016 fn refresh_unseen(&mut self) -> bool {
3019 let Some(imap) = &self.imap else {
3020 return false;
3021 };
3022 let account = imap.facts.account.name.clone();
3023 let wanted: Vec<(String, String)> = self
3024 .config
3025 .mail
3026 .mailboxes
3027 .iter()
3028 .filter_map(|spec| match remote::parse_spec(spec) {
3029 Some((a, folder)) if a == account => Some((spec.clone(), folder.to_string())),
3030 _ => None,
3031 })
3032 .collect();
3033 if wanted.is_empty() {
3034 return false;
3035 }
3036 let (specs, folders): (Vec<String>, Vec<String>) = wanted.into_iter().unzip();
3037 self.start(Job::Unseen(folders), Pending::Counts(specs))
3038 }
3039
3040 pub fn store_edited(&mut self, path: &Path, edited: &[u8]) {
3045 match &self.imap {
3049 Some(imap) => {
3050 let Some(flags) = self
3051 .visible
3052 .get(self.sel)
3053 .map(|&i| self.msgs[i].env.file.flags)
3054 else {
3055 return;
3056 };
3057 let mailbox = imap.facts.mailbox.clone();
3058 let original = path.to_path_buf();
3059 self.send_then(
3060 Job::Append {
3061 mailbox: Some(mailbox),
3062 flags,
3063 body: edited.to_vec(),
3064 },
3065 true,
3066 Box::new(move |session, done| {
3067 if let Err(err) = done {
3068 session.error(format!("cannot store the edited copy: {err:#}"));
3069 return;
3070 }
3071 let rules = session.delete_rules();
3072 if let Some(m) = session
3073 .msgs
3074 .iter_mut()
3075 .find(|m| m.env.file.path == original)
3076 && rules.mark(m)
3077 {
3078 m.dirty = true;
3079 }
3080 session.check_new_mail();
3081 session.note("edited copy appended; original marked deleted ($ purges)");
3082 }),
3083 );
3084 }
3085 None => {
3086 if let Err(err) = std::fs::write(path, edited) {
3087 self.error(format!("cannot write message: {err}"));
3088 return;
3089 }
3090 self.rescan();
3091 self.note("message edited");
3092 }
3093 }
3094 }
3095
3096 pub fn mark_all_read(&mut self) {
3103 if self.deny_readonly() {
3104 return;
3105 }
3106 let targets: Vec<usize> = (0..self.msgs.len())
3107 .filter(|&i| {
3108 let f = &self.msgs[i].env.file;
3109 !f.flags.seen || f.is_new
3110 })
3111 .collect();
3112 if targets.is_empty() {
3113 self.note("no unread messages");
3114 return;
3115 }
3116 self.push_undo("mark all read", &targets);
3117 for &i in &targets {
3118 let m = &mut self.msgs[i];
3119 m.env.file.flags.seen = true;
3120 m.env.file.is_new = false;
3121 m.dirty = true;
3122 }
3123 self.note(format!("{} marked read", targets.len()));
3124 }
3125
3126 pub fn tag_span(&mut self, a: usize, b: usize) {
3130 if self.visible.is_empty() {
3131 return;
3132 }
3133 let last = self.visible.len() - 1;
3134 let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
3135 let (lo, hi) = (lo.min(last), hi.min(last));
3136 let targets: Vec<usize> = self.visible[lo..=hi].to_vec();
3137 self.push_undo("tag", &targets);
3138 for &i in &targets {
3139 self.msgs[i].env.tagged = true;
3140 }
3141 self.note(format!("{} tagged", targets.len()));
3142 }
3143
3144 pub fn mark_old_unread(&mut self) {
3145 if self.read_only || !self.config.mail.mark_old.unwrap_or(true) {
3146 return;
3147 }
3148 for m in &mut self.msgs {
3149 if m.env.file.is_new
3150 && !m.env.file.flags.seen
3151 && !m.env.file.flags.deleted
3152 && let Ok(path) = maildir::store_flags(&m.env.file)
3153 {
3154 m.env.file.path = path;
3155 m.env.file.is_new = false;
3156 }
3157 }
3158 }
3159
3160 pub fn ready_to_leave(&mut self) -> bool {
3164 if self.deleted_count() > 0 {
3165 self.error("deleted messages pending; sync with $ or undelete first");
3166 return false;
3167 }
3168 if self.pending_count() > 0 {
3169 self.sync(false);
3170 if self.pending_count() > 0 {
3171 return false; }
3173 }
3174 true
3175 }
3176
3177 pub fn smtp_account(&self) -> Option<Account> {
3181 if std::env::var("RMUT_SENDMAIL").is_ok() || self.config.mail.sendmail.is_some() {
3182 return None;
3183 }
3184 if let Some(imap) = &self.imap
3185 && imap.facts.account.smtp_host.is_some()
3186 {
3187 return Some(imap.facts.account.clone());
3188 }
3189 self.config
3190 .accounts
3191 .iter()
3192 .find(|a| a.smtp_host.is_some())
3193 .cloned()
3194 }
3195
3196 pub fn copy_message(&mut self, input: &str, delete: bool, tagged: bool, decode: bool) {
3200 if input.is_empty() {
3201 self.error("no mailbox given");
3202 return;
3203 }
3204 let targets = self.op_targets(tagged);
3205 if targets.is_empty() {
3206 return;
3207 }
3208 let paths = self.target_paths(tagged);
3210 if !self.have_bodies(
3211 &paths,
3212 Again::Copy {
3213 input: input.to_string(),
3214 delete,
3215 tagged,
3216 decode,
3217 },
3218 ) {
3219 return;
3220 }
3221 if let Some((account, folder)) = remote::parse_spec(input)
3224 && self
3225 .imap
3226 .as_ref()
3227 .is_some_and(|imap| imap.facts.account.name == account)
3228 {
3229 let target = format!("imap:{account}/{folder}");
3230 let mut errors: Vec<String> = Vec::new();
3231 let mut paths = Vec::new();
3232 let mut messages = Vec::new();
3233 for &i in &targets {
3234 match self.copy_bytes(i, decode) {
3235 Ok(bytes) => {
3236 paths.push(self.msgs[i].env.file.path.clone());
3237 messages.push((self.msgs[i].env.file.flags, bytes));
3238 }
3239 Err(err) => errors.push(err),
3240 }
3241 }
3242 if messages.is_empty() {
3243 let verb = if delete { "save" } else { "copy" };
3244 self.error(format!("cannot {verb}: {}", errors.join("; ")));
3245 return;
3246 }
3247 let job = Job::AppendAll {
3248 mailbox: folder.to_string(),
3249 messages,
3250 };
3251 self.send_then(
3252 job,
3253 true,
3254 Box::new(move |session, done| {
3255 let outcomes = match done {
3256 Ok(Done::Appended(outcomes)) => outcomes,
3257 Ok(_) => Vec::new(),
3258 Err(err) => vec![Err(format!("{err:#}"))],
3259 };
3260 let tried = outcomes.len();
3261 let mut copied = Vec::new();
3262 for (path, outcome) in paths.iter().zip(outcomes) {
3263 match outcome {
3264 Ok(()) => copied
3267 .extend(session.msgs.iter().position(|m| m.env.file.path == *path)),
3268 Err(err) => errors.push(err),
3269 }
3270 }
3271 if paths.len() > tried.max(1) {
3272 errors.push(format!("{} not tried", paths.len() - tried.max(1)));
3273 }
3274 let note = Some(format!("the copy in {target} stays"));
3275 session.finish_copy(Copied {
3276 copied,
3277 errors,
3278 created: Vec::new(),
3279 target,
3280 note,
3281 delete,
3282 tagged,
3283 });
3284 }),
3285 );
3286 return;
3287 }
3288 let mut created: Vec<PathBuf> = Vec::new();
3291 let mut copied = Vec::new();
3292 let mut errors: Vec<String> = Vec::new();
3293 let mut target = String::new();
3294 for &i in &targets {
3295 match self.copy_one(i, input, decode, &mut created) {
3296 Ok(shown) => {
3297 target = shown;
3298 copied.push(i);
3299 }
3300 Err(err) => errors.push(err),
3303 }
3304 }
3305 self.finish_copy(Copied {
3306 copied,
3307 errors,
3308 created,
3309 target,
3310 note: None,
3311 delete,
3312 tagged,
3313 });
3314 }
3315
3316 fn finish_copy(&mut self, done: Copied) {
3319 let Copied {
3320 copied,
3321 errors,
3322 created,
3323 target,
3324 note,
3325 delete,
3326 tagged,
3327 } = done;
3328 let verb = if delete { "save" } else { "copy" };
3329 if copied.is_empty() {
3330 self.error(format!("cannot {verb}: {}", errors.join("; ")));
3331 return;
3332 }
3333 let marks = copied.iter().map(|&i| self.mark(i)).collect();
3334 self.push_undo_step(UndoStep {
3335 what: format!("{verb} to {target}"),
3336 marks,
3337 sel: self.selected_path(),
3338 created,
3339 note,
3340 rewritten: Vec::new(),
3341 });
3342 let n = copied.len();
3343 if delete {
3344 let rules = self.delete_rules();
3345 for &i in &copied {
3346 if rules.mark(&mut self.msgs[i]) {
3347 self.msgs[i].dirty = true;
3348 }
3349 }
3350 }
3351 if delete
3355 && !tagged
3356 && errors.is_empty()
3357 && let Some(pos) = self.step_message(true, true)
3358 {
3359 self.sel = pos;
3360 }
3361 let mut status = match (delete, n) {
3362 (true, 1) => format!("saved to {target} (original marked deleted)"),
3363 (true, _) => format!("saved {n} to {target} (originals marked deleted)"),
3364 (false, 1) => format!("copied to {target}"),
3365 (false, _) => format!("copied {n} to {target}"),
3366 };
3367 if !errors.is_empty() {
3368 status += &format!("; {} failed: {}", errors.len(), errors.join("; "));
3369 self.error(status);
3370 } else {
3371 self.note(status);
3372 }
3373 }
3374
3375 fn copy_bytes(&mut self, i: usize, decode: bool) -> Result<Vec<u8>, String> {
3380 if decode {
3381 Ok(self.displayed_text(i)?.into_bytes())
3382 } else {
3383 self.message_bytes(i)
3384 .ok_or_else(|| "cannot read the message".into())
3385 }
3386 }
3387
3388 fn copy_one(
3393 &mut self,
3394 i: usize,
3395 spec: &str,
3396 decode: bool,
3397 created: &mut Vec<PathBuf>,
3398 ) -> Result<String, String> {
3399 if remote::parse_spec(spec).is_some() {
3400 return Err("can only save to a folder of the open account".into());
3401 }
3402 let flags = self.msgs[i].env.file.flags;
3403 let bytes = self.copy_bytes(i, decode)?;
3404 let dir = expand_tilde(spec);
3405 maildir::create(&dir)
3406 .and_then(|()| maildir::deliver(&dir, &bytes, flags))
3407 .map(|path| {
3408 created.push(path);
3409 dir.display().to_string()
3410 })
3411 .map_err(|err| format!("{err:#}"))
3412 }
3413
3414 pub fn create_alias(&mut self, nick: &str, addr: &str) {
3416 if nick.is_empty() || nick.contains(char::is_whitespace) {
3417 self.error("the alias nick must be one word");
3418 return;
3419 }
3420 match alias::append_to(self.config.mail.alias_file.as_deref(), nick, addr) {
3421 Ok(_) => self.note(format!("added: alias {nick} {addr}")),
3422 Err(err) => self.error(format!("cannot save the alias: {err:#}")),
3423 }
3424 }
3425
3426 pub fn pipe_message(&mut self, command: &str, tagged: bool) {
3430 if command.is_empty() {
3431 self.error("no command given");
3432 return;
3433 }
3434 let paths = self.target_paths(tagged);
3435 if !self.have_bodies(
3436 &paths,
3437 Again::Pipe {
3438 command: command.to_string(),
3439 tagged,
3440 },
3441 ) {
3442 return;
3443 }
3444 let targets = self.op_targets(tagged);
3445 let n = targets.len();
3446 let decode = self.config.mail.pipe_decode.unwrap_or(false);
3447 let split = self.config.mail.pipe_split.unwrap_or(false);
3448 let sep = self
3449 .config
3450 .mail
3451 .pipe_sep
3452 .clone()
3453 .unwrap_or_else(|| "\n".into());
3454 match self.run_over(&targets, command, decode, split, &sep) {
3455 Ok(()) => self.note(match n {
3456 1 => format!("piped to {command}"),
3457 _ => format!("piped {n} messages to {command}"),
3458 }),
3459 Err(err) => self.error(format!("pipe failed: {err}")),
3460 }
3461 }
3462
3463 pub fn bounce_current(&mut self, to: &str, tagged: bool) {
3466 let rcpts = compose::addresses(to);
3467 if rcpts.is_empty() {
3468 self.error(format!("cannot parse the addresses in {to:?}"));
3469 return;
3470 }
3471 let paths = self.target_paths(tagged);
3472 if !self.have_bodies(
3473 &paths,
3474 Again::Bounce {
3475 to: to.to_string(),
3476 tagged,
3477 },
3478 ) {
3479 return;
3480 }
3481 let targets = self.op_targets(tagged);
3482 let mut sent = 0usize;
3483 for i in targets {
3484 let Some(bytes) = self.message_bytes(i) else {
3485 return;
3486 };
3487 if let Err(err) = self.bounce_one(&bytes, to, &rcpts) {
3488 self.error(format!("bounce failed: {err:#}"));
3489 return;
3490 }
3491 sent += 1;
3492 }
3493 self.note(match sent {
3494 1 => format!("message bounced to {to}"),
3495 _ => format!("{sent} messages bounced to {to}"),
3496 });
3497 }
3498
3499 fn bounce_one(&mut self, bytes: &[u8], to: &str, rcpts: &[String]) -> Result<()> {
3502 let host = maildir::hostname();
3503 let from = self
3504 .current_identity(rcpts)
3505 .from_line()
3506 .unwrap_or_else(|| default_from(&host));
3507 let text = compose::bounce_text(
3508 bytes,
3509 &from,
3510 to,
3511 &compose::rfc2822_now(),
3512 &compose::make_message_id(&host),
3513 );
3514 let envelope_from = compose::bare_address(&from).unwrap_or_else(|| from.clone());
3515 match self.smtp_account() {
3516 Some(account) => account_password(&account).and_then(|password| {
3517 smtp::send(&account, &password, &envelope_from, rcpts, text.as_bytes())
3518 }),
3519 None => run_sendmail(
3520 text.as_bytes(),
3521 self.config.mail.sendmail.as_deref(),
3522 Some(rcpts),
3523 ),
3524 }
3525 }
3526
3527 pub fn open_message(&mut self) {
3530 let Some(path) = self.selected_path() else {
3531 return;
3532 };
3533 if !self.have_bodies(std::slice::from_ref(&path), Again::View) {
3534 return;
3535 }
3536 self.mark_read();
3537 match self.load_view(&path) {
3538 Ok(view) => self.requests.push(Request::ShowMessage(Box::new(view))),
3539 Err(err) => self.error(format!("cannot open message: {err:#}")),
3540 }
3541 }
3542
3543 pub fn load_view(&mut self, path: &Path) -> Result<message::MessageView> {
3546 self.settle();
3550 if remote::is_partial(path)
3552 && let Some(imap) = &mut self.imap
3553 {
3554 imap.blocking(Job::FetchBodies(vec![path.to_path_buf()]))
3555 .context("cannot fetch message")?;
3556 if let Some(m) = self.msgs.iter_mut().find(|m| m.env.file.path == path)
3558 && let Ok(raw) = std::fs::read(path)
3559 {
3560 m.env.lines = Some(message::body_lines(&raw));
3561 }
3562 }
3563 let mut view = message::load_with(path, &self.display)?;
3564 if let Ok(raw) = std::fs::read(path)
3567 && let Some(p) = pgp::view(&self.config.pgp, &raw)
3568 {
3569 match p.body {
3570 Some(pgp::Body::Entity(raw)) => {
3574 view.body = message::render_entity(&raw, &self.display);
3575 }
3576 Some(pgp::Body::Text(text)) => view.body = text,
3577 None => {}
3578 }
3579 view.body = format!("{}\n\n{}", p.note, view.body);
3580 }
3581 Ok(view)
3582 }
3583
3584 fn displayed_text(&mut self, i: usize) -> Result<String, String> {
3588 let path = self.msgs[i].env.file.path.clone();
3589 let view = self
3590 .load_view(&path)
3591 .map_err(|err| format!("cannot decode: {err:#}"))?;
3592 let mut text = String::new();
3593 for (name, value) in &view.brief {
3594 text += &format!("{name}: {value}\n");
3595 }
3596 text.push('\n');
3597 text += &view.body;
3598 if !text.ends_with('\n') {
3599 text.push('\n');
3600 }
3601 Ok(text)
3602 }
3603
3604 pub fn print_current(&mut self, tagged: bool) {
3608 let paths = self.target_paths(tagged);
3609 if !self.have_bodies(&paths, Again::Print { tagged }) {
3610 return;
3611 }
3612 let targets = self.op_targets(tagged);
3613 if targets.is_empty() {
3614 return;
3615 }
3616 let command = self
3617 .config
3618 .mail
3619 .print
3620 .clone()
3621 .unwrap_or_else(|| "lpr".into());
3622 let decode = self.config.mail.print_decode.unwrap_or(true);
3623 let split = self.config.mail.print_split.unwrap_or(false);
3624 let n = targets.len();
3625 match self.run_over(&targets, &command, decode, split, "\n") {
3626 Ok(()) => self.note(match n {
3627 1 => format!("printed via {command}"),
3628 _ => format!("printed {n} messages via {command}"),
3629 }),
3630 Err(err) => self.error(format!("print failed: {err}")),
3631 }
3632 }
3633
3634 fn op_one_bytes(&mut self, i: usize, decode: bool) -> Result<Vec<u8>, String> {
3637 if decode {
3638 self.displayed_text(i).map(String::into_bytes)
3639 } else {
3640 self.message_bytes(i)
3641 .map(|mut b| {
3642 if !b.ends_with(b"\n") {
3643 b.push(b'\n');
3644 }
3645 b
3646 })
3647 .ok_or_else(|| "cannot read the message".into())
3648 }
3649 }
3650
3651 fn run_over(
3655 &mut self,
3656 targets: &[usize],
3657 command: &str,
3658 decode: bool,
3659 split: bool,
3660 sep: &str,
3661 ) -> Result<(), String> {
3662 if split {
3663 for &i in targets {
3664 let bytes = self.op_one_bytes(i, decode)?;
3665 pipe_to(command, &bytes).map_err(|err| format!("{err:#}"))?;
3666 }
3667 return Ok(());
3668 }
3669 let mut all: Vec<u8> = Vec::new();
3670 for (n, &i) in targets.iter().enumerate() {
3671 if n > 0 {
3672 all.extend_from_slice(sep.as_bytes());
3673 }
3674 all.extend_from_slice(&self.op_one_bytes(i, decode)?);
3675 }
3676 pipe_to(command, &all).map_err(|err| format!("{err:#}"))
3677 }
3678
3679 pub fn current_identity(&self, rcpts: &[String]) -> rmut_core::config::Identity {
3682 self.config.identity_for(
3683 &self.title,
3684 rcpts,
3685 self.imap.as_ref().map(|imap| &imap.facts.account),
3686 )
3687 }
3688
3689 pub fn matching_message_hooks(&self) -> Vec<usize> {
3691 let Some(env) = self.visible.get(self.sel).map(|&mi| &self.msgs[mi].env) else {
3692 return Vec::new();
3693 };
3694 let pos = pattern::Position {
3697 number: self.sel + 1,
3698 current: self.sel + 1,
3699 last: self.visible.len(),
3700 duplicate: false,
3701 };
3702 self.message_hooks
3703 .iter()
3704 .enumerate()
3705 .filter(|(_, h)| pattern::matches_in(&h.patterns, env, self.scope(pos), None))
3706 .map(|(i, _)| i)
3707 .collect()
3708 }
3709
3710 pub fn reply_hook_lines(&self, path: &Path) -> Vec<String> {
3714 if self.reply_hooks.is_empty() {
3715 return Vec::new();
3716 }
3717 let Some(env) = self.msgs.iter().find(|m| m.env.file.path == *path) else {
3718 return Vec::new();
3719 };
3720 let scope = self.scope(pattern::Position::default());
3721 self.reply_hooks
3722 .iter()
3723 .filter(|h| pattern::matches_in(&h.patterns, &env.env, scope, None))
3724 .map(|h| h.value.clone())
3725 .collect()
3726 }
3727
3728 pub fn fcc_hook_target(&self, draft: &str, path: &Path) -> Option<String> {
3731 if self.fcc_hooks.is_empty() {
3732 return None;
3733 }
3734 let env = compose::draft_envelope(draft, path);
3735 let scope = self.scope(pattern::Position::default());
3736 self.fcc_hooks
3737 .iter()
3738 .find(|h| pattern::matches_in(&h.patterns, &env, scope, None))
3739 .map(|h| h.value.clone())
3740 }
3741
3742 pub fn crypt_key_for(&self, address: &str) -> Option<String> {
3745 self.crypt_hooks
3746 .iter()
3747 .find(|(m, _)| m.is_match(address))
3748 .map(|(_, key)| key.clone())
3749 }
3750
3751 pub fn notmuch_mirror(&mut self, query: &str) -> Option<(PathBuf, usize)> {
3759 if query.is_empty() {
3760 return None;
3761 }
3762 let out = Command::new("notmuch")
3763 .args(["search", "--output=files", "--limit=1000", "--", query])
3764 .output();
3765 let out = match out {
3766 Ok(out) if out.status.success() => out,
3767 Ok(out) => {
3768 let err = String::from_utf8_lossy(&out.stderr);
3769 self.error(format!("notmuch: {}", err.trim()));
3770 return None;
3771 }
3772 Err(err) => {
3773 self.error(format!("notmuch: {err}"));
3774 return None;
3775 }
3776 };
3777 let stdout = String::from_utf8_lossy(&out.stdout);
3778 let files: Vec<&str> = stdout.lines().filter(|l| !l.trim().is_empty()).collect();
3779 if files.is_empty() {
3780 self.error("notmuch: no matches");
3781 return None;
3782 }
3783 if !self.ready_to_leave() {
3784 return None;
3785 }
3786 let dir = remote::cache_base().join("notmuch");
3787 let build = || -> Result<()> {
3788 for sub in ["cur", "new", "tmp"] {
3789 std::fs::create_dir_all(dir.join(sub))?;
3790 }
3791 for entry in std::fs::read_dir(dir.join("cur"))?.flatten() {
3792 let _ = std::fs::remove_file(entry.path());
3793 }
3794 for (i, file) in files.iter().enumerate() {
3795 let base = Path::new(file)
3796 .file_name()
3797 .map(|n| n.to_string_lossy().into_owned())
3798 .unwrap_or_else(|| format!("{i}"));
3799 let _ = std::os::unix::fs::symlink(
3802 file,
3803 dir.join("cur").join(format!("{i:04}.{base}")),
3804 );
3805 }
3806 Ok(())
3807 };
3808 if let Err(err) = build() {
3809 self.note(format!("notmuch mirror: {err:#}"));
3810 return None;
3811 }
3812 Some((dir, files.len()))
3813 }
3814
3815 pub fn compose_base(&self) -> Option<ComposeBase> {
3816 let &mi = self.visible.get(self.sel)?;
3817 let env = &self.msgs[mi].env;
3818 let view = message::load(&env.file.path).ok()?;
3819 let get = |name: &str| {
3820 view.all
3821 .iter()
3822 .filter(|(k, _)| k.eq_ignore_ascii_case(name))
3823 .map(|(_, v)| v.clone())
3824 .collect::<Vec<_>>()
3825 .join(", ")
3826 };
3827 let from_hdr = get("From");
3828 let (reply_to, has_reply_to) = {
3829 let rt = get("Reply-To");
3830 if rt.trim().is_empty() {
3831 (from_hdr.clone(), false)
3832 } else {
3833 let differs = rt.trim() != from_hdr.trim();
3834 (rt, differs)
3835 }
3836 };
3837 Some(ComposeBase {
3838 path: env.file.path.clone(),
3839 reply_to,
3840 from_hdr,
3841 has_reply_to,
3842 orig_to: get("To"),
3843 orig_cc: get("Cc"),
3844 list_post: compose::list_post_address(&get("List-Post")),
3845 followup_to: get("Mail-Followup-To"),
3846 from_addr: compose::addresses(&get("From"))
3847 .into_iter()
3848 .next()
3849 .unwrap_or_default(),
3850 from_display: env.from.clone(),
3851 subject: env.subject.clone(),
3852 date: env.date,
3853 msg_id: env.msg_id.clone(),
3854 references: env.references.clone(),
3855 })
3856 }
3857
3858 pub fn list_target(&self, base: &ComposeBase) -> Option<String> {
3864 if let Some(addr) = &base.list_post {
3865 return Some(addr.clone());
3866 }
3867 let mut candidates = compose::addresses(&base.orig_to);
3868 candidates.extend(compose::addresses(&base.orig_cc));
3869 candidates
3870 .into_iter()
3871 .find(|a| self.lists.iter().any(|m| m.is_match(a)))
3872 }
3873
3874 pub fn followup_header(&self, to: &str, cc: Option<&str>, from: &str) -> Option<String> {
3878 if self.lists.is_empty() {
3879 return None;
3880 }
3881 let mut rcpts = compose::addresses(to);
3882 rcpts.extend(compose::addresses(cc.unwrap_or_default()));
3883 if !rcpts
3884 .iter()
3885 .any(|a| self.lists.iter().any(|m| m.is_match(a)))
3886 {
3887 return None;
3888 }
3889 let subscribed = rcpts
3890 .iter()
3891 .any(|a| self.subscribed.iter().any(|m| m.is_match(a)));
3892 let value = compose::followup_to(to, cc.unwrap_or_default(), self.me(), subscribed, from);
3893 (!value.is_empty()).then_some(value)
3894 }
3895
3896 pub fn edit_headers(&self) -> bool {
3899 self.config.mail.edit_headers.unwrap_or(false)
3900 }
3901
3902 pub fn stage_draft(&self, text: &str) -> Result<(PathBuf, Option<String>)> {
3906 let text = compose::apply_my_hdr(text, &self.config.mail.my_hdr);
3910 if self.edit_headers() {
3911 return Ok((write_draft(&text)?, None));
3912 }
3913 let (head, body) = text.split_once("\n\n").unwrap_or((text.trim_end(), ""));
3914 Ok((write_draft(body)?, Some(head.to_string())))
3915 }
3916
3917 pub fn compose_from(&self, base: Option<&ComposeBase>, to: &str) -> Option<String> {
3921 let realname = self.config.identity.reverse_realname.unwrap_or(true);
3925 if self.config.identity.reverse_name
3926 && let Some(b) = base
3927 && let Some(from) = compose::reverse_from(&b.orig_to, &b.orig_cc, self.me(), realname)
3928 {
3929 let rcpts = compose::addresses(to);
3930 return Some(match self.current_identity(&rcpts).name {
3931 Some(name) if !realname && !from.contains('<') => format!("{name} <{from}>"),
3932 _ => from,
3933 });
3934 }
3935 let rcpts = compose::addresses(to);
3936 self.current_identity(&rcpts).from_line()
3937 }
3938
3939 pub fn forward_attaches(&self) -> bool {
3940 self.config.mail.forward.as_deref() == Some("attach")
3941 }
3942
3943 pub fn default_fcc(&self, draft: Option<&Compose>) -> String {
3951 if let Some(compose) = draft
3952 && let Ok(full) = draft_full(compose)
3953 && let Some(mailbox) = self.fcc_hook_target(&full, &compose.path)
3954 {
3955 return mailbox;
3956 }
3957 match &self.imap {
3958 Some(imap) => format!(
3959 "imap:{}/{}",
3960 imap.facts.account.name, imap.facts.account.sent_folder
3961 ),
3962 None => self.config.mail.sent.clone().unwrap_or_default(),
3963 }
3964 }
3965
3966 pub fn default_security(&self) -> Security {
3968 Self::combine(
3969 self.config.pgp.sign_by_default,
3970 self.config.pgp.encrypt_by_default,
3971 )
3972 }
3973
3974 fn combine(sign: bool, encrypt: bool) -> Security {
3975 match (sign, encrypt) {
3976 (true, true) => Security::Both,
3977 (true, false) => Security::Sign,
3978 (false, true) => Security::Encrypt,
3979 (false, false) => Security::None,
3980 }
3981 }
3982
3983 pub fn security_for(&self, kind: &ComposeKind, base: Option<&ComposeBase>) -> Security {
3990 let mut sign = self.config.pgp.sign_by_default;
3991 let mut encrypt = self.config.pgp.encrypt_by_default;
3992 let is_reply = matches!(
3993 kind,
3994 ComposeKind::Reply | ComposeKind::GroupReply | ComposeKind::ListReply
3995 );
3996 if is_reply
3997 && let Some(base) = base
3998 && let Ok(raw) = std::fs::read(&base.path)
3999 {
4000 let crypto = rmut_core::pgp::classify(&raw);
4001 if crypto.encrypted && self.config.pgp.reply_encrypt {
4002 encrypt = true;
4003 }
4004 if crypto.signed && self.config.pgp.reply_sign {
4005 sign = true;
4006 }
4007 if crypto.encrypted && self.config.pgp.reply_sign_encrypted {
4013 sign = true;
4014 }
4015 }
4016 Self::combine(sign, encrypt)
4017 }
4018
4019 pub fn tick_outbox(&mut self) {
4022 if self.holding() {
4025 return;
4026 }
4027 while self.outbox.first().is_some_and(|h| h.due <= Instant::now()) {
4028 let held = self.outbox.remove(0);
4029 self.deliver(held);
4030 }
4031 if let Some(next) = self.outbox.first()
4032 && !self.notice().is_some_and(|n| n.is_error())
4033 {
4034 let left = next.due.saturating_duration_since(Instant::now()).as_secs() + 1;
4035 self.note(format!("sending {} in {left}s (z cancels)", next.label));
4036 }
4037 }
4038
4039 pub fn flush_outbox(&mut self) -> Vec<String> {
4046 let mut trouble = Vec::new();
4047 while !self.outbox.is_empty() {
4048 let held = self.outbox.remove(0);
4049 let label = held.label.clone();
4050 self.deliver(held);
4051 if let Some(err) = self.notice().filter(|n| n.is_error()).map(|n| n.text()) {
4052 trouble.push(format!("{label}: {err}"));
4053 self.clear_notice();
4054 }
4055 }
4056 trouble
4057 }
4058
4059 pub fn hold_send(&mut self, held: Held) {
4063 self.outbox.push(held);
4064 }
4065
4066 pub fn cancel_send(&mut self) -> bool {
4069 let Some(held) = self.outbox.pop() else {
4070 return false;
4071 };
4072 self.note(format!("send cancelled: {}", held.label));
4073 self.hand_back(held.state);
4074 true
4075 }
4076
4077 pub fn deliver(&mut self, held: Held) {
4082 self.settle();
4086 let Held {
4087 state: compose_state,
4088 text: final_text,
4089 fcc: chosen,
4090 ..
4091 } = held;
4092 let send_result = match self.smtp_account() {
4093 Some(account) => send_via_smtp(&account, &final_text),
4094 None => run_sendmail(
4095 final_text.as_bytes(),
4096 self.config.mail.sendmail.as_deref(),
4097 None,
4098 ),
4099 };
4100 match send_result {
4101 Ok(()) => {
4102 let mut note = String::from("message sent");
4103 let mut fcc_to_server = None;
4104 let skip_copy = chosen.as_deref() == Some("")
4105 || (chosen.is_none() && self.config.mail.copy == Some(false));
4106 if skip_copy {
4107 } else if let Some(fcc) = chosen
4109 .as_deref()
4110 .filter(|f| Some(*f) != Some(self.default_fcc(None).as_str()))
4111 {
4112 let dir = expand_tilde(fcc);
4114 let flags = maildir::Flags {
4115 seen: true,
4116 ..Default::default()
4117 };
4118 if dir.join("cur").is_dir()
4119 && maildir::deliver(&dir, final_text.as_bytes(), flags).is_ok()
4120 {
4121 note += &format!(", copy in {fcc}");
4122 } else {
4123 note += &format!(", Fcc to {fcc} failed");
4124 }
4125 } else {
4126 match &mut self.imap {
4127 Some(_) => fcc_to_server = Some(final_text.clone().into_bytes()),
4132 None => {
4133 let sent_dir = self
4134 .config
4135 .mail
4136 .sent
4137 .as_deref()
4138 .map(expand_tilde)
4139 .filter(|p| p.join("cur").is_dir())
4140 .or_else(|| {
4141 maildir::find_special(&self.dir, &["sent", "sent-mail"])
4142 });
4143 match sent_dir {
4144 Some(sent) => {
4145 let flags = maildir::Flags {
4146 seen: true,
4147 ..Default::default()
4148 };
4149 match maildir::deliver(&sent, final_text.as_bytes(), flags) {
4150 Ok(_) => note += ", copy in Sent",
4151 Err(_) => note += ", Fcc to Sent failed",
4152 }
4153 }
4154 None => note += " (no Sent maildir, no copy kept)",
4155 }
4156 }
4157 }
4158 }
4159 if let Ok(full) = draft_full(&compose_state) {
4161 for a in compose::extract_attachments(&full).1 {
4162 if a.unlink {
4163 let _ = std::fs::remove_file(&a.path);
4164 }
4165 }
4166 }
4167 let _ = std::fs::remove_file(&compose_state.path);
4168 if let Some(src) = &compose_state.recall_source {
4169 let _ = std::fs::remove_file(src);
4170 }
4171 match fcc_to_server {
4172 Some(body) => self.fcc_to_sent(note, body),
4173 None => self.note(note),
4174 }
4175 }
4176 Err(err) => {
4177 self.error(format!("send failed: {err:#}"));
4178 self.hand_back(compose_state);
4179 }
4180 }
4181 }
4182
4183 fn fcc_to_sent(&mut self, sent: String, body: Vec<u8>) {
4187 self.send_then(
4188 Job::Append {
4189 mailbox: None,
4190 flags: maildir::Flags {
4191 seen: true,
4192 ..Default::default()
4193 },
4194 body,
4195 },
4196 false,
4197 Box::new(move |session, done| match done {
4198 Ok(Done::Folder(folder)) => session.note(format!("{sent}, copy in {folder}")),
4199 Ok(_) => session.note(format!("{sent}, copy in Sent")),
4200 Err(err) => session.error(format!("{sent}, Fcc to Sent failed: {err:#}")),
4201 }),
4202 );
4203 }
4204
4205 pub fn attachment_forgotten(&self, raw: &str, compose: &Compose) -> bool {
4210 if self.config.mail.abort_noattach.as_deref().unwrap_or("no") == "no" {
4211 return false;
4212 }
4213 if compose.attach.is_some() || !compose::extract_attachments(raw).1.is_empty() {
4214 return false;
4215 }
4216 let body = raw.split_once("\n\n").map_or("", |(_, body)| body);
4217 for line in body.lines() {
4218 if line.trim_end() == "--" || line == "-- " {
4219 break;
4220 }
4221 if self.quote_re.is_match(line) {
4222 continue;
4223 }
4224 if self.attach_re.is_match(line) {
4225 return true;
4226 }
4227 }
4228 false
4229 }
4230
4231 pub fn secure_message(
4235 &self,
4236 security: Security,
4237 text: String,
4238 files: &[compose::Attachment],
4239 original: Option<&[u8]>,
4240 ) -> Result<String> {
4241 let cfg = &self.config.pgp;
4242 let recipients = |text: &str| -> Result<Vec<String>> {
4245 let (mut rcpts, _) = compose::smtp_envelope(text)?;
4246 if let Some(from) = compose::from_address(text) {
4247 rcpts.push(from);
4248 }
4249 for r in &mut rcpts {
4252 if let Some(key) = self.crypt_key_for(r) {
4253 *r = key;
4254 }
4255 }
4256 rcpts.sort();
4257 rcpts.dedup();
4258 Ok(rcpts)
4259 };
4260 let flowed = self.config.mail.text_flowed;
4261 if files.is_empty() && original.is_none() {
4262 return match security {
4263 Security::None if flowed => Ok(compose::flow_plain(&text)),
4266 Security::None => Ok(text),
4267 Security::Sign => pgp::sign_message(cfg, &text, flowed),
4268 Security::Encrypt | Security::Both => pgp::encrypt_message(
4269 cfg,
4270 &recipients(&text)?,
4271 security == Security::Both,
4272 &text,
4273 flowed,
4274 ),
4275 };
4276 }
4277 let (head, body) = text.split_once("\n\n").unwrap_or((text.trim_end(), ""));
4278 let entity = compose::mixed_entity(body, files, original, flowed)?;
4279 match security {
4280 Security::None => Ok(format!("{}\nMIME-Version: 1.0\n{entity}", head.trim_end())),
4281 Security::Sign => pgp::sign_entity(cfg, head, entity.as_bytes()),
4282 Security::Encrypt | Security::Both => pgp::encrypt_entity(
4283 cfg,
4284 &recipients(&text)?,
4285 security == Security::Both,
4286 head,
4287 entity.as_bytes(),
4288 ),
4289 }
4290 }
4291
4292 pub fn postponed_dir(&self) -> Option<PathBuf> {
4293 self.config
4294 .mail
4295 .postponed
4296 .as_deref()
4297 .map(expand_tilde)
4298 .filter(|p| p.join("cur").is_dir())
4299 .or_else(|| {
4300 maildir::find_special(&self.dir, &["drafts", "postponed", "rmut-postponed"])
4301 })
4302 }
4303
4304 pub fn has_postponed(&self) -> bool {
4305 self.postponed_dir()
4306 .and_then(|d| maildir::scan(&d).ok())
4307 .is_some_and(|files| !files.is_empty())
4308 }
4309
4310 pub fn postpone_draft(&mut self, compose_state: Compose) {
4313 let target = match self.postponed_dir() {
4314 Some(d) => Ok(d),
4315 None => {
4316 let d = self.dir.join(".rmut-postponed");
4317 maildir::create(&d).map(|()| d)
4318 }
4319 };
4320 let result = target.and_then(|dir| {
4321 let bytes = draft_full(&compose_state)?.into_bytes();
4324 let flags = maildir::Flags {
4325 draft: true,
4326 seen: true,
4327 ..Default::default()
4328 };
4329 maildir::deliver(&dir, &bytes, flags)
4330 });
4331 match result {
4332 Ok(path) => {
4333 let _ = std::fs::remove_file(&compose_state.path);
4334 self.note(format!("postponed to {}", path.display()));
4335 }
4336 Err(err) => {
4337 self.error(format!(
4338 "postpone failed: {err:#}; draft at {}",
4339 compose_state.path.display()
4340 ));
4341 }
4342 }
4343 }
4344
4345 pub fn recall_file(&mut self, source: PathBuf) -> Option<Compose> {
4348 let result = std::fs::read_to_string(&source)
4349 .map_err(anyhow::Error::from)
4350 .and_then(|content| self.stage_draft(&content));
4351 match result {
4352 Ok((path, hidden_head)) => Some(Compose {
4353 path,
4354 recall_source: Some(source),
4355 security: self.default_security(),
4356 attach: None,
4357 hidden_head,
4358 fcc: None,
4359 }),
4360 Err(err) => {
4361 self.error(format!("cannot recall: {err:#}"));
4362 None
4363 }
4364 }
4365 }
4366
4367 pub fn draft_head(&self) -> String {
4369 let Some(c) = &self.draft else {
4370 return String::new();
4371 };
4372 match &c.hidden_head {
4373 Some(head) => head.clone(),
4374 None => {
4375 let text = std::fs::read_to_string(&c.path).unwrap_or_default();
4376 match text.split_once("\n\n") {
4377 Some((head, _)) => head.to_string(),
4378 None => text.trim_end().to_string(),
4379 }
4380 }
4381 }
4382 }
4383
4384 pub fn edit_draft_head(&mut self, f: impl Fn(&str) -> String) {
4386 let Some(c) = &mut self.draft else {
4387 return;
4388 };
4389 match &mut c.hidden_head {
4390 Some(head) => *head = f(head),
4391 None => {
4392 if let Ok(text) = std::fs::read_to_string(&c.path) {
4393 let (head, body) = text.split_once("\n\n").unwrap_or((text.trim_end(), ""));
4394 let _ = std::fs::write(&c.path, format!("{}\n\n{body}", f(head)));
4395 }
4396 }
4397 }
4398 }
4399
4400 pub fn set_draft_header(&mut self, name: &str, value: &str) {
4402 let value = value.trim().to_string();
4403 let name = name.to_string();
4404 self.edit_draft_head(|head| {
4405 let mut lines: Vec<&str> = head
4406 .lines()
4407 .filter(|l| {
4408 !l.split_once(':')
4409 .is_some_and(|(k, _)| k.trim().eq_ignore_ascii_case(&name))
4410 })
4411 .collect();
4412 let added = format!("{name}: {value}");
4413 if !value.is_empty() {
4414 lines.push(&added);
4415 }
4416 lines.join("\n")
4417 });
4418 }
4419
4420 pub fn draft_header(&self, name: &str) -> String {
4421 header_value(&self.draft_head(), name).unwrap_or_default()
4422 }
4423
4424 pub fn send_draft(&mut self) -> Option<Ask> {
4428 let compose_state = self.draft.take()?;
4429 let raw = match draft_full(&compose_state) {
4430 Ok(r) => r,
4431 Err(err) => {
4432 self.error(format!("cannot read draft: {err}"));
4433 return None;
4434 }
4435 };
4436 if !mem::take(&mut self.attach_confirmed) && self.attachment_forgotten(&raw, &compose_state)
4439 {
4440 self.draft = Some(compose_state);
4441 match self.config.mail.abort_noattach.as_deref() {
4442 Some("yes") => {
4445 self.error("no attachment: not sent (abort_noattach); a attaches one");
4446 self.requests.push(Request::ShowDraft);
4447 return None;
4448 }
4449 _ => {
4450 return Some(Ask::Key {
4451 label:
4452 "The body mentions an attachment and none is attached. Send? (y/n): "
4453 .into(),
4454 what: AskKind::NoAttach,
4455 });
4456 }
4457 }
4458 }
4459 let draft_path = self
4462 .draft
4463 .as_ref()
4464 .map(|c| c.path.clone())
4465 .unwrap_or_default();
4466 let hook_fcc = self.fcc_hook_target(&raw, &draft_path);
4467 let (raw, files) = compose::extract_attachments(&raw);
4468 let host = maildir::hostname();
4469 let from = self
4470 .current_identity(&[])
4471 .from_line()
4472 .unwrap_or_else(|| default_from(&host));
4473 let msg_host = self
4475 .config
4476 .mail
4477 .hostname
4478 .clone()
4479 .filter(|h| !h.trim().is_empty())
4480 .unwrap_or(host);
4481 let final_text = match compose::finalize_with(
4482 &raw,
4483 &from,
4484 &compose::make_message_id(&msg_host),
4485 &compose::rfc2822_now(),
4486 self.config.mail.user_agent.unwrap_or(false),
4487 ) {
4488 Ok(t) => t,
4489 Err(err) => {
4490 self.error(format!("{err}; press e to edit"));
4491 self.hand_back(compose_state);
4492 return None;
4493 }
4494 };
4495 let original = match &compose_state.attach {
4496 Some(path) => match std::fs::read(path) {
4497 Ok(bytes) => Some(bytes),
4498 Err(err) => {
4499 self.error(format!("cannot attach the original: {err}"));
4500 self.hand_back(compose_state);
4501 return None;
4502 }
4503 },
4504 None => None,
4505 };
4506 let final_text = match self.secure_message(
4507 compose_state.security,
4508 final_text,
4509 &files,
4510 original.as_deref(),
4511 ) {
4512 Ok(t) => t,
4513 Err(err) => {
4514 self.error(format!("{err:#}; e edits, s changes security"));
4515 self.hand_back(compose_state);
4516 return None;
4517 }
4518 };
4519 let held = Held {
4522 text: final_text,
4523 fcc: compose_state.fcc.clone().or(hook_fcc),
4524 label: {
4525 let head = raw.split_once("\n\n").map_or(raw.as_str(), |(h, _)| h);
4526 header_value(head, "Subject")
4527 .filter(|s| !s.trim().is_empty())
4528 .or_else(|| header_value(head, "To"))
4529 .unwrap_or_else(|| "message".into())
4530 },
4531 state: compose_state,
4532 due: Instant::now() + Duration::from_secs(self.config.mail.undo_send),
4533 };
4534 if self.config.mail.undo_send > 0 {
4536 self.note(format!(
4537 "sending {} in {}s (z cancels)",
4538 held.label, self.config.mail.undo_send
4539 ));
4540 self.hold_send(held);
4541 return None;
4542 }
4543 self.deliver(held);
4544 None
4545 }
4546
4547 fn confirm_attachment(&mut self) {
4550 self.attach_confirmed = true;
4551 }
4552
4553 fn hand_back(&mut self, draft: Compose) {
4555 self.draft = Some(draft);
4556 self.requests.push(Request::ShowDraft);
4557 }
4558
4559 pub fn draft(&self) -> Option<&Compose> {
4561 self.draft.as_ref()
4562 }
4563
4564 pub fn draft_mut(&mut self) -> Option<&mut Compose> {
4565 self.draft.as_mut()
4566 }
4567
4568 pub fn take_draft(&mut self) -> Option<Compose> {
4571 self.draft.take()
4572 }
4573
4574 pub fn set_draft(&mut self, draft: Compose) {
4581 if let Some((path, staged)) = self.staged.take()
4582 && self.config.mail.abort_unmodified.unwrap_or(true)
4583 && path == draft.path
4584 && std::fs::read_to_string(&draft.path).is_ok_and(|now| now == staged)
4585 {
4586 let _ = std::fs::remove_file(&draft.path);
4587 self.draft = None;
4588 self.error("aborted unmodified message");
4589 return;
4590 }
4591 self.draft = Some(draft);
4592 }
4593
4594 pub fn set_attach_field(&mut self, k: usize, input: &str, is_type: bool) {
4599 let value = input.trim().to_string();
4600 self.edit_draft_head(|head| {
4601 let mut seen = 0usize;
4602 head.lines()
4603 .map(|l| {
4604 let is_attach = l
4605 .split_once(':')
4606 .is_some_and(|(key, _)| key.trim().eq_ignore_ascii_case("attach"));
4607 if is_attach {
4608 let (_, mut atts) = compose::extract_attachments(l);
4611 if let Some(mut a) = atts.pop() {
4612 let idx = seen;
4613 seen += 1;
4614 if idx == k {
4615 let new = (!value.is_empty()).then(|| value.clone());
4616 if is_type {
4617 a.mime = new;
4618 } else {
4619 a.description = new;
4620 }
4621 return compose::attach_line(&a);
4622 }
4623 }
4624 }
4625 l.to_string()
4626 })
4627 .collect::<Vec<_>>()
4628 .join("\n")
4629 });
4630 }
4631
4632 pub fn attach_index(&mut self, sel: usize) -> Option<usize> {
4638 let fixed = 1 + usize::from(self.draft().is_some_and(|c| c.attach.is_some()));
4639 if sel < fixed {
4640 self.error("only Attach: files can be changed here");
4641 return None;
4642 }
4643 Some(sel - fixed)
4644 }
4645
4646 pub fn attachments(&self) -> Vec<compose::Attachment> {
4648 self.draft()
4649 .and_then(|c| draft_full(c).ok())
4650 .map(|full| compose::extract_attachments(&full).1)
4651 .unwrap_or_default()
4652 }
4653
4654 pub fn edit_attachment(&mut self, k: usize, f: impl Fn(&mut compose::Attachment)) {
4656 self.edit_draft_head(|head| {
4657 let mut seen = 0usize;
4658 head.lines()
4659 .map(|l| {
4660 let is_attach = l
4661 .split_once(':')
4662 .is_some_and(|(key, _)| key.trim().eq_ignore_ascii_case("attach"));
4663 if is_attach {
4664 let (_, mut atts) = compose::extract_attachments(l);
4665 if let Some(mut a) = atts.pop() {
4666 let idx = seen;
4667 seen += 1;
4668 if idx == k {
4669 f(&mut a);
4670 return compose::attach_line(&a);
4671 }
4672 }
4673 }
4674 l.to_string()
4675 })
4676 .collect::<Vec<_>>()
4677 .join("\n")
4678 });
4679 }
4680
4681 pub fn toggle_unlink(&mut self, sel: usize) {
4684 let Some(k) = self.attach_index(sel) else {
4685 return;
4686 };
4687 self.edit_attachment(k, |a| a.unlink = !a.unlink);
4688 let on = self.attachments().get(k).is_some_and(|a| a.unlink);
4689 self.note(if on {
4690 "the file will be deleted after sending"
4691 } else {
4692 "the file will be kept after sending"
4693 });
4694 }
4695
4696 pub fn toggle_disposition(&mut self, sel: usize) {
4698 let Some(k) = self.attach_index(sel) else {
4699 return;
4700 };
4701 self.edit_attachment(k, |a| a.inline = !a.inline);
4702 }
4703
4704 pub fn move_attachment(&mut self, sel: usize, up: bool) -> bool {
4707 let Some(k) = self.attach_index(sel) else {
4708 return false;
4709 };
4710 let n = self.attachments().len();
4711 let other = if up {
4712 k.checked_sub(1)
4713 } else {
4714 (k + 1 < n).then_some(k + 1)
4715 };
4716 let Some(other) = other else { return false };
4717 self.edit_draft_head(|head| {
4718 let mut lines: Vec<String> = head.lines().map(String::from).collect();
4719 let attach_rows: Vec<usize> = lines
4720 .iter()
4721 .enumerate()
4722 .filter(|(_, l)| {
4723 l.split_once(':')
4724 .is_some_and(|(key, _)| key.trim().eq_ignore_ascii_case("attach"))
4725 })
4726 .map(|(i, _)| i)
4727 .collect();
4728 if let (Some(&a), Some(&b)) = (attach_rows.get(k), attach_rows.get(other)) {
4729 lines.swap(a, b);
4730 }
4731 lines.join("\n")
4732 });
4733 true
4734 }
4735
4736 fn push_attach_line(&mut self, line: &str) -> std::io::Result<()> {
4738 let Some(c) = self.draft_mut() else {
4739 return Ok(());
4740 };
4741 match &mut c.hidden_head {
4742 Some(head) => {
4743 *head = format!("{}\n{line}", head.trim_end());
4744 Ok(())
4745 }
4746 None => std::fs::read_to_string(&c.path).and_then(|text| {
4747 let updated = match text.split_once("\n\n") {
4748 Some((head, body)) => format!("{head}\n{line}\n\n{body}"),
4749 None => format!("{}\n{line}\n", text.trim_end()),
4750 };
4751 std::fs::write(&c.path, updated)
4752 }),
4753 }
4754 }
4755
4756 pub fn attach_messages(&mut self) {
4762 if self.draft().is_none() {
4763 return;
4764 }
4765 let tagged = self.msgs.iter().any(|m| m.env.tagged);
4766 let paths = self.target_paths(tagged);
4767 let mut attached = 0usize;
4768 for path in paths {
4769 if remote::is_partial(&path) {
4770 self.error(format!(
4771 "{} is not fetched yet: open it first",
4772 path.file_name().and_then(|n| n.to_str()).unwrap_or("?")
4773 ));
4774 continue;
4775 }
4776 let mut a = compose::Attachment::of(path);
4777 a.mime = Some("message/rfc822".into());
4778 a.inline = true;
4779 let line = compose::attach_line(&a);
4780 match self.push_attach_line(&line) {
4781 Ok(()) => attached += 1,
4782 Err(err) => self.error(format!("cannot attach: {err}")),
4783 }
4784 }
4785 if attached > 0 {
4786 self.note(format!("attached {attached} message(s)"));
4787 }
4788 self.requests.push(Request::ShowDraft);
4789 }
4790
4791 pub fn new_mime(&mut self, path: &str, mime: &str) {
4795 if mime.is_empty() {
4796 self.requests.push(Request::ShowDraft);
4797 return;
4798 }
4799 if !mime.contains('/') || mime.starts_with('/') || mime.ends_with('/') {
4800 self.error("Content-Type is of the form base/sub");
4801 self.requests.push(Request::ShowDraft);
4802 return;
4803 }
4804 let file = expand_tilde(path);
4805 if !file.exists()
4806 && let Err(err) = std::fs::write(&file, b"")
4807 {
4808 self.error(format!("cannot create {}: {err}", file.display()));
4809 self.requests.push(Request::ShowDraft);
4810 return;
4811 }
4812 let mut a = compose::Attachment::of(file.clone());
4813 a.mime = Some(mime.to_string());
4814 let line = compose::attach_line(&a);
4815 if let Err(err) = self.push_attach_line(&line) {
4816 self.error(format!("cannot attach: {err}"));
4817 } else {
4818 self.requests.push(Request::EditFile(file));
4819 }
4820 self.requests.push(Request::ShowDraft);
4821 }
4822
4823 pub fn ispell_command(&self) -> Option<String> {
4826 let path = self.draft()?.path.display().to_string();
4827 let ispell = self.config.mail.ispell.as_deref().unwrap_or("ispell");
4828 Some(format!("{ispell} -x '{}'", path.replace('\'', "'\\''")))
4829 }
4830
4831 fn outgoing_text(&self, c: &Compose) -> Result<String> {
4834 let raw = draft_full(c).context("reading the draft")?;
4835 let (raw, files) = compose::extract_attachments(&raw);
4836 let host = maildir::hostname();
4837 let from = self
4838 .current_identity(&[])
4839 .from_line()
4840 .unwrap_or_else(|| default_from(&host));
4841 let msg_host = self
4842 .config
4843 .mail
4844 .hostname
4845 .clone()
4846 .filter(|h| !h.trim().is_empty())
4847 .unwrap_or(host);
4848 let text = compose::finalize_with(
4849 &raw,
4850 &from,
4851 &compose::make_message_id(&msg_host),
4852 &compose::rfc2822_now(),
4853 self.config.mail.user_agent.unwrap_or(false),
4854 )
4855 .map_err(|e| anyhow::anyhow!("{e}"))?;
4856 let original = match &c.attach {
4857 Some(path) => Some(std::fs::read(path).context("reading the original")?),
4858 None => None,
4859 };
4860 self.secure_message(c.security, text, &files, original.as_deref())
4861 }
4862
4863 pub fn write_draft_to(&mut self, mailbox: &str) {
4867 let Some(c) = self.draft() else { return };
4868 let text = match self.outgoing_text(c) {
4869 Ok(t) => t,
4870 Err(err) => {
4871 self.error(format!("cannot write the message: {err:#}"));
4872 return;
4873 }
4874 };
4875 let flags = maildir::Flags {
4876 seen: true,
4877 ..Default::default()
4878 };
4879 match remote::parse_spec(mailbox) {
4880 Some((account, folder)) => {
4881 if !self
4882 .imap
4883 .as_ref()
4884 .is_some_and(|i| i.facts.account.name == account)
4885 {
4886 self.error("write-fcc to IMAP needs a folder of the open account");
4887 return;
4888 }
4889 let mailbox = mailbox.to_string();
4890 self.send_then(
4891 Job::Append {
4892 mailbox: Some(folder.to_string()),
4893 flags,
4894 body: text.into_bytes(),
4895 },
4896 false,
4897 Box::new(move |session, done| match done {
4898 Ok(_) => session.note(format!("Message written to {mailbox}.")),
4899 Err(err) => session.error(format!("write failed: {err:#}")),
4900 }),
4901 );
4902 }
4903 None => {
4904 let dir = expand_tilde(mailbox);
4905 if !dir.join("cur").is_dir() {
4906 self.error(format!("{mailbox} is not a maildir"));
4907 return;
4908 }
4909 match maildir::deliver(&dir, text.as_bytes(), flags) {
4910 Ok(_) => self.note(format!("Message written to {mailbox}.")),
4911 Err(err) => self.error(format!("write failed: {err:#}")),
4912 }
4913 }
4914 }
4915 }
4916
4917 pub fn detach(&mut self, sel: usize) {
4918 let fixed = 1 + usize::from(self.draft().is_some_and(|c| c.attach.is_some()));
4919 if sel < fixed {
4920 self.error("only Attach: files can be detached");
4921 return;
4922 }
4923 let k = sel - fixed;
4924 self.edit_draft_head(|head| {
4925 let mut seen = 0usize;
4926 head.lines()
4927 .filter(|l| {
4928 let is_attach = l
4929 .split_once(':')
4930 .is_some_and(|(key, _)| key.trim().eq_ignore_ascii_case("attach"));
4931 if is_attach {
4932 seen += 1;
4933 seen - 1 != k
4934 } else {
4935 true
4936 }
4937 })
4938 .collect::<Vec<_>>()
4939 .join("\n")
4940 });
4941 }
4942
4943 pub fn attach_file(&mut self, input: &str) {
4947 let input = input.trim();
4948 if !input.is_empty() {
4949 if !expand_tilde(input).is_file() {
4950 self.error(format!("{input} is not a file"));
4951 } else if let Some(c) = self.draft_mut() {
4952 let value = if input.contains(char::is_whitespace) && !input.starts_with('"') {
4955 format!("\"{input}\"")
4956 } else {
4957 input.to_string()
4958 };
4959 let result = match &mut c.hidden_head {
4960 Some(head) => {
4962 *head = format!("{}\nAttach: {value}", head.trim_end());
4963 Ok(())
4964 }
4965 None => std::fs::read_to_string(&c.path).and_then(|text| {
4966 let updated = match text.split_once("\n\n") {
4967 Some((head, body)) => format!("{head}\nAttach: {value}\n\n{body}"),
4968 None => format!("{}\nAttach: {value}\n", text.trim_end()),
4969 };
4970 std::fs::write(&c.path, updated)
4971 }),
4972 };
4973 if let Err(err) = result {
4974 self.error(format!("cannot attach: {err}"));
4975 }
4976 }
4977 }
4978 self.requests.push(Request::ShowDraft);
4979 }
4980
4981 pub fn error(&mut self, msg: impl Into<String>) {
4982 self.notify(Notice::Error(msg.into()));
4983 }
4984
4985 pub fn note(&mut self, msg: impl Into<String>) {
4987 self.notify(Notice::Info(msg.into()));
4988 }
4989
4990 fn notify(&mut self, notice: Notice) {
4995 if let Notice::Error(text) = ¬ice {
4996 let keep = self.config.ui.error_history;
4997 self.error_history.push_back(text.clone());
4998 while self.error_history.len() > keep {
4999 self.error_history.pop_front();
5000 }
5001 }
5002 if (notice.is_error() && self.config.ui.beep)
5005 || (notice.is_new_mail() && self.config.ui.beep_new)
5006 {
5007 use std::io::Write as _;
5008 let mut out = std::io::stdout();
5009 let _ = out.write_all(b"\x07");
5010 let _ = out.flush();
5011 }
5012 self.notices.notice(notice);
5013 self.spoken = true;
5014 }
5015
5016 pub fn notice(&self) -> Option<&Notice> {
5019 self.notices.latest()
5020 }
5021}
5022
5023fn subject_key(subject: &str) -> String {
5025 let mut key = subject.trim().to_lowercase();
5026 loop {
5027 let stripped = key
5028 .strip_prefix("re:")
5029 .or_else(|| key.strip_prefix("fwd:"))
5030 .or_else(|| key.strip_prefix("fw:"))
5031 .map(|rest| rest.trim_start().to_string());
5032 match stripped {
5033 Some(next) => key = next,
5034 None => break,
5035 }
5036 }
5037 key
5038}
5039
5040pub fn parse_sort(spec: &str) -> Option<(SortKey, bool)> {
5042 let (rev, name) = match spec.strip_prefix("reverse-") {
5043 Some(rest) => (true, rest),
5044 None => (false, spec),
5045 };
5046 let key = match name {
5047 "date" | "date-sent" | "date-received" => SortKey::Date,
5048 "from" => SortKey::From,
5049 "subject" => SortKey::Subject,
5050 "size" => SortKey::Size,
5051 "threads" => SortKey::Threads,
5052 "label" => SortKey::Label,
5053 "to" => SortKey::To,
5054 "unsorted" | "mailbox-order" => SortKey::Unsorted,
5055 _ => return None,
5056 };
5057 Some((key, rev && key != SortKey::Threads))
5059}
5060
5061impl pattern::EnvSource for Session {
5062 fn envelope(&self, i: usize) -> Option<&Envelope> {
5063 self.msgs.get(i).map(|m| &m.env)
5064 }
5065}
5066
5067fn dir_mtimes(dir: &Path) -> (Option<SystemTime>, Option<SystemTime>) {
5068 let mtime = |p: PathBuf| p.metadata().and_then(|m| m.modified()).ok();
5069 (mtime(dir.join("new")), mtime(dir.join("cur")))
5070}
5071
5072pub fn account_password(account: &Account) -> Result<String> {
5075 if !matches!(account.auth_kind()?, rmut_core::config::AuthKind::Password) {
5076 return account.secret();
5077 }
5078 static CACHE: Mutex<Option<HashMap<String, String>>> = Mutex::new(None);
5079 let mut cache = CACHE.lock().unwrap();
5080 let map = cache.get_or_insert_with(HashMap::new);
5081 if let Some(password) = map.get(&account.name) {
5082 return Ok(password.clone());
5083 }
5084 let password = account.password()?;
5085 map.insert(account.name.clone(), password.clone());
5086 Ok(password)
5087}
5088
5089pub fn wrap_order(n: usize, sel: usize, forward: bool) -> Vec<(usize, bool)> {
5094 (1..=n)
5095 .map(|step| {
5096 if forward {
5097 ((sel + step) % n, sel + step >= n)
5098 } else {
5099 ((sel + n - (step % n)) % n, step > sel)
5100 }
5101 })
5102 .collect()
5103}
5104
5105pub fn sort_browser(dirs: &mut [(String, usize)], how: Option<&str>) {
5113 let how = how.unwrap_or("alpha");
5114 let (reverse, key) = match how.strip_prefix("reverse-") {
5115 Some(key) => (true, key),
5116 None => (false, how),
5117 };
5118 match key {
5119 "unsorted" => {}
5120 "count" | "unread" => dirs.sort_by_key(|(_, count)| *count),
5121 "date" => dirs.sort_by_cached_key(|(spec, _)| {
5122 if spec.starts_with("imap:") {
5123 return std::time::SystemTime::UNIX_EPOCH;
5124 }
5125 let dir = expand_tilde(spec);
5126 ["new", "cur"]
5127 .iter()
5128 .filter_map(|sub| std::fs::metadata(dir.join(sub)).ok())
5129 .filter_map(|m| m.modified().ok())
5130 .max()
5131 .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
5132 }),
5133 _ => dirs.sort_by(|a, b| a.0.cmp(&b.0)),
5134 }
5135 if reverse {
5136 dirs.reverse();
5137 }
5138}
5139
5140pub fn expand_tilde(input: &str) -> PathBuf {
5141 if let Some(rest) = input.strip_prefix("~/")
5142 && let Ok(home) = std::env::var("HOME")
5143 {
5144 return Path::new(&home).join(rest);
5145 }
5146 PathBuf::from(input)
5147}
5148
5149pub fn send_via_smtp(account: &Account, text: &str) -> Result<()> {
5150 let from = compose::from_address(text).context("cannot parse the From address")?;
5151 let (rcpts, text) = compose::smtp_envelope(text)?;
5152 anyhow::ensure!(!rcpts.is_empty(), "no recipient addresses");
5153 let password = account_password(account)?;
5154 smtp::send(account, &password, &from, &rcpts, text.as_bytes())
5155}
5156
5157pub fn pipe_to(command: &str, bytes: &[u8]) -> Result<()> {
5159 let mut child = Command::new("sh")
5160 .arg("-c")
5161 .arg(command)
5162 .stdin(Stdio::piped())
5163 .stdout(Stdio::null())
5164 .stderr(Stdio::null())
5165 .spawn()
5166 .with_context(|| format!("running {command}"))?;
5167 {
5171 let mut stdin = child.stdin.take().context("no stdin on the child")?;
5172 if let Err(err) = stdin.write_all(bytes)
5173 && err.kind() != std::io::ErrorKind::BrokenPipe
5174 {
5175 return Err(err.into());
5176 }
5177 }
5178 let status = child.wait()?;
5179 anyhow::ensure!(status.success(), "{command} exited with {status}");
5180 Ok(())
5181}
5182
5183pub fn run_sendmail(
5186 bytes: &[u8],
5187 configured: Option<&str>,
5188 rcpts: Option<&[String]>,
5189) -> Result<()> {
5190 let command = std::env::var("RMUT_SENDMAIL")
5191 .ok()
5192 .or_else(|| configured.map(String::from));
5193 let (prog, mut args) = match command {
5194 Some(v) => {
5195 let mut it = v.split_whitespace().map(String::from);
5196 let p = it.next().context("sendmail command is empty")?;
5197 (p, it.collect::<Vec<_>>())
5198 }
5199 None => {
5200 let p = if Path::new("/usr/sbin/sendmail").exists() {
5201 "/usr/sbin/sendmail".to_string()
5202 } else {
5203 "sendmail".to_string()
5204 };
5205 (p, Vec::new())
5206 }
5207 };
5208 match rcpts {
5209 Some(rcpts) => {
5210 args.push("-oi".into());
5211 args.extend(rcpts.iter().cloned());
5212 }
5213 None => args.extend(["-t".into(), "-oi".into()]),
5214 }
5215 let mut child = Command::new(&prog)
5216 .args(&args)
5217 .stdin(Stdio::piped())
5218 .stdout(Stdio::null())
5219 .stderr(Stdio::null())
5220 .spawn()
5221 .with_context(|| format!("running {prog}"))?;
5222 child
5223 .stdin
5224 .take()
5225 .context("no stdin on sendmail child")?
5226 .write_all(bytes)?;
5227 let status = child.wait()?;
5228 anyhow::ensure!(status.success(), "{prog} exited with {status}");
5229 Ok(())
5230}
5231
5232pub fn default_from(hostname: &str) -> String {
5233 if let Ok(email) = std::env::var("EMAIL") {
5234 return email;
5235 }
5236 let user = std::env::var("USER").unwrap_or_else(|_| "user".into());
5237 format!("{user}@{hostname}")
5238}
5239
5240const DEFAULT_ATTACH_KEYWORD: &str = r"\b(attach|attached|attaching|attachment|attachments)\b";
5243
5244fn attach_re_from_config(config: &Config, warnings: &mut Vec<String>) -> regex_lite::Regex {
5247 let spec = config
5248 .mail
5249 .attach_keyword
5250 .clone()
5251 .unwrap_or_else(|| DEFAULT_ATTACH_KEYWORD.to_string());
5252 match regex_lite::Regex::new(&format!("(?i){spec}")) {
5253 Ok(re) => re,
5254 Err(err) => {
5255 warnings.push(format!("bad attach_keyword {spec:?}: {err}"));
5256 default_attach_re()
5257 }
5258 }
5259}
5260
5261fn default_attach_re() -> regex_lite::Regex {
5262 regex_lite::Regex::new(&format!("(?i){DEFAULT_ATTACH_KEYWORD}")).expect("the default compiles")
5263}
5264
5265fn reply_re_from_config(config: &Config, warnings: &mut Vec<String>) -> regex_lite::Regex {
5268 match &config.mail.reply_regexp {
5269 Some(spec) => match compose::reply_regexp(spec) {
5270 Ok(re) => re,
5271 Err(err) => {
5272 warnings.push(format!("bad reply_regexp {spec:?}: {err}"));
5273 compose::default_reply_regexp()
5274 }
5275 },
5276 None => compose::default_reply_regexp(),
5277 }
5278}
5279
5280fn quote_re_from_config(config: &Config, warnings: &mut Vec<String>) -> regex_lite::Regex {
5283 match &config.pager.quote_regexp {
5284 Some(spec) => match regex_lite::Regex::new(spec) {
5285 Ok(re) => re,
5286 Err(err) => {
5287 warnings.push(format!("bad quote_regexp {spec:?}: {err}"));
5288 default_quote_re()
5289 }
5290 },
5291 None => default_quote_re(),
5292 }
5293}
5294
5295fn display_from_config(config: &Config) -> message::Display {
5298 let mut rules = message::HeaderRules::default();
5302 let clean = |list: &Vec<String>| {
5303 list.iter()
5304 .map(|n| n.trim_end_matches(':').to_lowercase())
5305 .collect::<Vec<_>>()
5306 };
5307 if let Some(list) = &config.pager.ignore {
5308 rules.ignore = clean(list);
5309 }
5310 if let Some(list) = &config.pager.unignore {
5311 rules.unignore = clean(list);
5312 }
5313 if let Some(list) = &config.pager.hdr_order {
5314 rules.order = clean(list);
5315 }
5316 let mut filters: HashMap<String, String> = HashMap::new();
5321 let mut mailcap: Option<Vec<rmut_core::mailcap::Entry>> = None;
5322 for (mimetype, command) in &config.filters {
5323 let mimetype = mimetype.to_lowercase();
5324 if !command.trim().is_empty() {
5325 filters.insert(mimetype, command.clone());
5326 continue;
5327 }
5328 let entries = mailcap.get_or_insert_with(rmut_core::mailcap::load);
5329 if let Some(command) = rmut_core::mailcap::command_for(entries, &mimetype) {
5330 filters.insert(mimetype, command);
5331 }
5332 }
5333 message::Display {
5334 filters,
5335 rules,
5336 reflow: config.pager.reflow_text.unwrap_or(true),
5337 alternative_order: config
5338 .pager
5339 .alternative_order
5340 .iter()
5341 .map(|t| t.to_lowercase())
5342 .collect(),
5343 html_to_text: config.pager.html.as_deref() != Some("raw"),
5344 }
5345}
5346
5347fn compile_hooks<'a>(
5350 what: &str,
5351 entries: impl Iterator<Item = (&'a String, &'a String)>,
5352 warnings: &mut Vec<String>,
5353) -> Vec<Hook> {
5354 let mut out = Vec::new();
5355 for (spec, value) in entries {
5356 if value.trim().is_empty() {
5357 warnings.push(format!("{what} {spec:?} has nothing to do"));
5358 continue;
5359 }
5360 match pattern::parse(spec) {
5361 Ok(patterns) => out.push(Hook {
5362 patterns,
5363 value: value.clone(),
5364 }),
5365 Err(err) => warnings.push(format!("bad {what} pattern {spec:?}: {err}")),
5366 }
5367 }
5368 out
5369}
5370
5371pub fn header_value(head: &str, name: &str) -> Option<String> {
5373 head.lines().find_map(|l| {
5374 let (k, v) = l.split_once(':')?;
5375 k.trim()
5376 .eq_ignore_ascii_case(name)
5377 .then(|| v.trim().to_string())
5378 })
5379}
5380
5381pub fn draft_full(c: &Compose) -> std::io::Result<String> {
5384 let text = std::fs::read_to_string(&c.path)?;
5385 Ok(match &c.hidden_head {
5386 Some(head) => format!("{}\n\n{}", head.trim_end(), text),
5387 None => text,
5388 })
5389}
5390
5391pub fn default_quote_re() -> regex_lite::Regex {
5393 regex_lite::Regex::new(r"^([ \t]*[|>:}#])+").expect("default quote_regexp compiles")
5394}
5395
5396pub fn write_draft(text: &str) -> Result<PathBuf> {
5397 use std::sync::atomic::{AtomicUsize, Ordering};
5398 static COUNTER: AtomicUsize = AtomicUsize::new(0);
5399 let path = std::env::temp_dir().join(format!(
5400 "rmut-draft-{}-{}.eml",
5401 std::process::id(),
5402 COUNTER.fetch_add(1, Ordering::Relaxed),
5403 ));
5404 std::fs::write(&path, text).with_context(|| format!("writing {}", path.display()))?;
5405 Ok(path)
5406}