1use std::collections::{HashMap, HashSet};
10use std::io::Write as _;
11use std::mem;
12use std::path::{Path, PathBuf};
13use std::process::{Command, Stdio};
14use std::sync::Mutex;
15use std::time::{Duration, Instant, SystemTime};
16
17use anyhow::{Context, Result};
18use rmut_core::config::{Account, Config};
19use rmut_core::message::Envelope;
20use rmut_core::notice::{Notice, NoticeSink};
21use rmut_core::pattern::{self, Pattern};
22use rmut_core::remote::{self, Remote};
23use rmut_core::{alias, compose, hdrcache, maildir, mbox, message, pgp, smtp, thread};
24
25mod ask;
26mod commands;
27mod drafts;
28mod function;
29#[cfg(test)]
30mod tests;
31mod worker;
32
33pub use ask::{Answer, Ask, AskKind, Key, PatternOp, Request, Wants};
34pub use commands::CommandRun;
35pub use function::{FrontOp, Function, Outcome, PageSpot, SidebarOp};
36pub use worker::{Done, Facts, Imap, Job, Manage};
37
38const UNDO_MAX_STEPS: usize = 32;
42const UNDO_MAX_MARKS: usize = 100_000;
43
44pub struct Msg {
45 pub env: message::Envelope,
46 pub dirty: bool,
48 pub purge: bool,
51}
52
53impl Msg {
54 pub fn pending(&self) -> bool {
55 self.dirty || self.env.file.flags.deleted
56 }
57}
58
59#[derive(Clone)]
63pub struct MsgMark {
64 pub path: PathBuf,
65 pub flags: maildir::Flags,
66 pub is_new: bool,
67 pub tagged: bool,
68 pub dirty: bool,
69}
70
71pub struct UndoStep {
74 pub what: String,
75 pub marks: Vec<MsgMark>,
76 pub sel: Option<PathBuf>,
79 pub created: Vec<PathBuf>,
82 pub note: Option<String>,
85 pub rewritten: Vec<(PathBuf, Vec<u8>)>,
88}
89
90#[derive(Clone, Copy, PartialEq, Eq, Debug)]
91pub enum SortKey {
92 Date,
93 From,
94 Subject,
95 Size,
96 Threads,
97 Label,
99 To,
101 Unsorted,
104}
105
106impl SortKey {
107 pub fn name(self) -> &'static str {
108 match self {
109 SortKey::Date => "date",
110 SortKey::From => "from",
111 SortKey::Subject => "subject",
112 SortKey::Size => "size",
113 SortKey::Threads => "threads",
114 SortKey::Label => "label",
115 SortKey::To => "to",
116 SortKey::Unsorted => "unsorted",
117 }
118 }
119}
120
121#[derive(Clone, Copy, PartialEq, Eq)]
122pub enum ThreadOp {
123 Delete,
124 Undelete,
125 Tag,
126 Read,
128}
129
130fn mailbox_exists(spec: &str) -> bool {
134 match remote::parse_spec(spec) {
135 Some(_) => true,
136 None => expand_tilde(spec).join("cur").is_dir(),
137 }
138}
139
140pub struct Hook {
143 patterns: Vec<Pattern>,
144 pub value: String,
146}
147
148#[derive(Clone, Copy, PartialEq, Eq)]
149pub enum ComposeKind {
150 New,
151 Reply,
152 GroupReply,
153 ListReply,
155 Forward,
156}
157
158pub struct ComposeBase {
161 pub path: PathBuf,
162 pub reply_to: String,
163 pub from_hdr: String,
166 pub has_reply_to: bool,
169 pub orig_to: String,
170 pub orig_cc: String,
171 pub list_post: Option<String>,
173 pub followup_to: String,
176 pub from_addr: String,
178 pub from_display: String,
179 pub subject: String,
180 pub date: i64,
181 pub msg_id: Option<String>,
182 pub references: Vec<String>,
183}
184
185pub struct ComposeSetup {
186 pub kind: ComposeKind,
187 pub base: Option<ComposeBase>,
188 pub to: Option<String>,
189 pub cc: Option<String>,
191 pub bcc: Option<String>,
192 pub subject_prefill: Option<String>,
195 pub subject: Option<String>,
197 pub fwd_attach: Option<bool>,
199}
200
201#[derive(Clone, Copy, PartialEq, Eq, Debug)]
203pub enum Security {
204 None,
205 Sign,
206 Encrypt,
207 Both,
208}
209
210impl Security {
211 pub fn label(self) -> &'static str {
212 match self {
213 Security::None => "",
214 Security::Sign => "sign",
215 Security::Encrypt => "encrypt",
216 Security::Both => "sign+encrypt",
217 }
218 }
219}
220
221pub struct Compose {
223 pub path: PathBuf,
224 pub recall_source: Option<PathBuf>,
226 pub security: Security,
227 pub attach: Option<PathBuf>,
230 pub hidden_head: Option<String>,
233 pub fcc: Option<String>,
236}
237
238pub struct Held {
243 pub state: Compose,
244 pub text: String,
245 pub fcc: Option<String>,
248 pub label: String,
250 pub due: Instant,
251}
252
253#[derive(Clone, Copy)]
259pub struct DeleteRules {
260 pub flag_safe: bool,
261 pub untag: bool,
262}
263
264impl DeleteRules {
265 pub fn mark(self, m: &mut Msg) -> bool {
269 if self.flag_safe && m.env.file.flags.flagged {
270 return false;
271 }
272 m.env.file.flags.deleted = true;
273 if self.untag {
274 m.env.tagged = false;
275 }
276 true
277 }
278}
279
280enum Pending {
281 CheckNew,
283 Sync { purge: bool },
286 Again(Again),
289 Counts(Vec<String>),
291}
292
293#[derive(Clone)]
296enum Again {
297 View,
299 Copy {
300 input: String,
301 delete: bool,
302 tagged: bool,
303 decode: bool,
304 },
305 Pipe {
306 command: String,
307 tagged: bool,
308 },
309 Print {
310 tagged: bool,
311 },
312 Bounce {
313 to: String,
314 tagged: bool,
315 },
316}
317
318pub struct Session {
320 pub dir: PathBuf,
323 pub title: String,
326 imap: Option<Imap>,
330 pub mbox: Option<mbox::Mbox>,
332 pub msgs: Vec<Msg>,
333 pub visible: Vec<usize>,
335 pub sel: usize,
337 pub sort: SortKey,
338 pub sort_rev: bool,
339 pub limit: Option<(String, Vec<Pattern>)>,
340 pub last_search: Option<Vec<Pattern>>,
341 pub search_rev: bool,
344 pub thread_depth: Vec<usize>,
347 pub thread_root: Vec<usize>,
348 pub thread_pseudo: Vec<bool>,
351 pub thread_parent: Vec<Option<usize>>,
355 pub thread_children: Vec<Vec<usize>>,
356 pub thread_members: Vec<Vec<usize>>,
357 pub collapsed: HashSet<PathBuf>,
359 undo: Vec<UndoStep>,
362 pub config: Config,
363 pub my_addresses: Vec<String>,
366 pub lists: Vec<pattern::Matcher>,
369 pub subscribed: Vec<pattern::Matcher>,
372 pub alternates: Vec<pattern::Matcher>,
375 body_hits: HashMap<String, HashSet<u32>>,
378 pub idle: Option<remote::IdleWatch>,
380 backfill: Option<remote::Backfill>,
382 mailbox_new: HashMap<String, usize>,
385 error_history: std::collections::VecDeque<String>,
388 mailbox_announced: HashSet<String>,
391 unseen: HashMap<String, usize>,
394 pub read_only: bool,
396 pub read_only_session: bool,
400 pub(crate) dir_mtimes: (Option<SystemTime>, Option<SystemTime>),
402 pub message_hooks: Vec<Hook>,
407 pub reply_hooks: Vec<Hook>,
408 fcc_hooks: Vec<Hook>,
409 crypt_hooks: Vec<(pattern::Matcher, String)>,
410 pub display: message::Display,
413 outbox: Vec<Held>,
416 pub quote_re: regex_lite::Regex,
419 pub reply_re: regex_lite::Regex,
421 attach_re: regex_lite::Regex,
423 pending: Option<Pending>,
426 deferred: Option<Again>,
430 progress_noted: bool,
433 aborted: bool,
436 quit_default: bool,
439 setup: Option<ComposeSetup>,
442 draft: Option<Compose>,
446 staged: Option<(PathBuf, String)>,
450 attach_confirmed: bool,
453 hook_base: Option<Box<Config>>,
456 active_message_hooks: Vec<usize>,
459 requests: Vec<Request>,
461 notices: Box<dyn NoticeSink>,
464}
465
466struct Silence;
468
469impl NoticeSink for Silence {
470 fn notice(&mut self, _notice: Notice) {}
471
472 fn latest(&self) -> Option<&Notice> {
473 None
474 }
475
476 fn clear(&mut self) {}
477}
478
479impl Session {
480 pub fn open(dir: &Path, config: Config) -> Result<(Session, Vec<String>)> {
486 let (envelopes, skipped) = hdrcache::load_envelopes(dir)?;
488 let mut msgs: Vec<Msg> = envelopes
489 .into_iter()
490 .map(|env| Msg {
491 env,
492 dirty: false,
493 purge: false,
494 })
495 .collect();
496 msgs.sort_by_key(|m| m.env.date);
498 let visible: Vec<usize> = (0..msgs.len()).collect();
499 let sel = msgs
501 .iter()
502 .position(|m| m.env.file.is_new)
503 .unwrap_or(visible.len().saturating_sub(1));
504 let mut warnings = Vec::new();
505 if skipped > 0 {
506 warnings.push(format!("{skipped} unreadable message(s) skipped"));
507 }
508 let count = msgs.len();
509 let mut me: Vec<String> = config
510 .identity
511 .email
512 .iter()
513 .chain(config.accounts.iter().map(|a| &a.user))
514 .chain(
515 config
516 .accounts
517 .iter()
518 .filter_map(|a| a.identity.as_ref().and_then(|i| i.email.as_ref())),
519 )
520 .chain(config.identities.iter().filter_map(|r| r.email.as_ref()))
521 .map(|a| a.to_lowercase())
522 .collect();
523 if let Ok(email) = std::env::var("EMAIL") {
524 me.push(email.to_lowercase());
525 }
526 let mut session = Session {
527 dir: dir.to_path_buf(),
528 title: dir.display().to_string(),
529 imap: None,
530 mbox: None,
531 msgs,
532 visible,
533 sel,
534 sort: SortKey::Date,
535 sort_rev: false,
536 limit: None,
537 last_search: None,
538 search_rev: false,
539 thread_depth: vec![0; count],
540 thread_root: (0..count).collect(),
541 thread_pseudo: vec![false; count],
542 thread_parent: vec![None; count],
543 thread_children: vec![Vec::new(); count],
544 thread_members: (0..count).map(|i| vec![i]).collect(),
545 collapsed: HashSet::new(),
546 undo: Vec::new(),
547 lists: config.list_matchers(),
548 subscribed: config.subscribed_matchers(),
549 alternates: config.alternate_matchers(),
550 my_addresses: me,
551 body_hits: HashMap::new(),
552 idle: None,
553 backfill: None,
554 mailbox_new: HashMap::new(),
555 error_history: std::collections::VecDeque::new(),
556 mailbox_announced: HashSet::new(),
557 unseen: HashMap::new(),
558 read_only: false,
559 read_only_session: false,
560 dir_mtimes: dir_mtimes(dir),
561 message_hooks: Vec::new(),
562 reply_hooks: Vec::new(),
563 fcc_hooks: Vec::new(),
564 crypt_hooks: Vec::new(),
565 display: display_from_config(&config),
566 outbox: Vec::new(),
567 quote_re: default_quote_re(),
568 reply_re: compose::default_reply_regexp(),
569 attach_re: default_attach_re(),
570 config,
571 pending: None,
572 deferred: None,
573 progress_noted: false,
574 aborted: false,
575 quit_default: true,
576 setup: None,
577 draft: None,
578 staged: None,
579 attach_confirmed: false,
580 hook_base: None,
581 active_message_hooks: Vec::new(),
582 requests: Vec::new(),
583 notices: Box::new(Silence),
584 };
585 session.apply_timeouts();
586 let mut hook_warnings = Vec::new();
587 session.quote_re = quote_re_from_config(&session.config, &mut hook_warnings);
588 session.reply_re = reply_re_from_config(&session.config, &mut hook_warnings);
589 session.attach_re = attach_re_from_config(&session.config, &mut hook_warnings);
590 session.compile_hooks_from_config(&mut hook_warnings);
591 hook_warnings.append(&mut warnings);
592 let mut warnings = hook_warnings;
593 if let Some(spec) = session.config.index.sort.clone() {
594 match parse_sort(&spec) {
595 Some((sort, rev)) => {
596 session.sort = sort;
597 session.sort_rev = rev;
598 session.resort(None);
599 }
600 None => warnings.push(format!("unknown sort {spec:?} in config")),
601 }
602 }
603 Ok((session, warnings))
604 }
605
606 pub fn open_spec(
613 spec: &str,
614 config: Config,
615 progress: remote::Progress,
616 ) -> Result<(Session, Vec<String>)> {
617 rmut_core::net::set_timeouts(config.net.connect_timeout, config.net.timeout);
620 rmut_core::net::set_trust(
621 config.net.system_cas,
622 config.net.certificate_file.as_deref().map(expand_tilde),
623 );
624 match remote::parse_spec(spec) {
625 Some((account_name, mailbox)) => {
626 let account = config
627 .account(account_name)
628 .with_context(|| format!("no account {account_name} in config"))?
629 .clone();
630 let password = account_password(&account)?;
631 let remote = Remote::open(&account, mailbox, &password, progress)?;
632 let cache = remote.cache.clone();
633 let (mut session, warnings) = Session::open(&cache, config)?;
634 session.title = remote.spec.clone();
635 session.idle = Some(remote::idle_watch(&account, &remote.mailbox, &password));
638 session.imap = Some(Imap::new(remote));
639 Ok((session, warnings))
640 }
641 None => {
642 let path = expand_tilde(spec);
643 if path.is_file() {
644 return Session::open_mbox(&path, config);
645 }
646 Session::open(&path, config)
647 }
648 }
649 }
650
651 pub fn open_mbox(path: &Path, config: Config) -> Result<(Session, Vec<String>)> {
654 let mbox = mbox::Mbox::open(path)?;
655 let cache = mbox.cache.clone();
656 let (mut session, warnings) = Session::open(&cache, config)?;
657 session.title = path.display().to_string();
658 session.mbox = Some(mbox);
659 Ok((session, warnings))
660 }
661
662 pub fn recompile(&mut self) -> Vec<String> {
665 let mut warnings = Vec::new();
666 self.apply_timeouts();
667 self.display = display_from_config(&self.config);
668 self.quote_re = quote_re_from_config(&self.config, &mut warnings);
669 self.reply_re = reply_re_from_config(&self.config, &mut warnings);
670 self.attach_re = attach_re_from_config(&self.config, &mut warnings);
671 self.compile_hooks_from_config(&mut warnings);
672 self.lists = self.config.list_matchers();
673 self.subscribed = self.config.subscribed_matchers();
674 self.alternates = self.config.alternate_matchers();
675 warnings
676 }
677
678 fn apply_timeouts(&self) {
682 rmut_core::net::set_timeouts(self.config.net.connect_timeout, self.config.net.timeout);
683 rmut_core::net::set_trust(
684 self.config.net.system_cas,
685 self.config
686 .net
687 .certificate_file
688 .as_deref()
689 .map(expand_tilde),
690 );
691 }
692
693 fn compile_hooks_from_config(&mut self, warnings: &mut Vec<String>) {
696 self.message_hooks = compile_hooks(
697 "message-hook",
698 self.config
699 .message_hooks
700 .iter()
701 .map(|h| (&h.pattern, &h.command)),
702 warnings,
703 );
704 self.reply_hooks = compile_hooks(
705 "reply-hook",
706 self.config
707 .reply_hooks
708 .iter()
709 .map(|h| (&h.pattern, &h.command)),
710 warnings,
711 );
712 self.fcc_hooks = compile_hooks(
713 "fcc-hook",
714 self.config
715 .fcc_hooks
716 .iter()
717 .map(|h| (&h.pattern, &h.mailbox)),
718 warnings,
719 );
720 self.crypt_hooks = self
721 .config
722 .crypt_hooks
723 .iter()
724 .map(|h| (pattern::Matcher::new(&h.address), h.key.clone()))
725 .collect();
726 }
727
728 pub fn switch_to(&mut self, spec: &str, progress: remote::Progress) -> Result<Vec<String>> {
738 self.settle();
742 let reuse = match remote::parse_spec(spec) {
743 Some((account, mailbox))
744 if self
745 .imap
746 .as_ref()
747 .is_some_and(|imap| imap.facts.account.name == account)
748 && self
749 .imap
750 .as_mut()
751 .is_some_and(|imap| imap.blocking(Job::Switch(mailbox.into())).is_ok()) =>
752 {
753 self.imap.take()
754 }
755 _ => None,
756 };
757 let config = self.config.clone();
758 let (mut next, warnings) = match &reuse {
759 Some(imap) => Session::open(&imap.facts.cache.clone(), config)?,
760 None => Session::open_spec(spec, config, progress)?,
761 };
762 if let Some(imap) = reuse {
763 next.title = imap.facts.spec.clone();
764 if let Ok(password) = account_password(&imap.facts.account) {
766 next.idle = Some(remote::idle_watch(
767 &imap.facts.account,
768 &imap.facts.mailbox,
769 &password,
770 ));
771 }
772 next.imap = Some(imap);
773 }
774 next.read_only_session = self.read_only_session;
777 next.read_only = self.read_only_session;
778 next.notices = mem::replace(&mut self.notices, Box::new(Silence));
779 *self = next;
780 Ok(warnings)
781 }
782
783 pub fn install_notices(&mut self, sink: Box<dyn NoticeSink>) {
786 self.notices = sink;
787 }
788
789 pub fn clear_notice(&mut self) {
792 self.notices.clear();
793 }
794
795 pub fn new_count(&self) -> usize {
796 self.msgs.iter().filter(|m| m.env.file.is_new).count()
797 }
798
799 pub fn deleted_count(&self) -> usize {
800 self.msgs
801 .iter()
802 .filter(|m| m.env.file.flags.deleted)
803 .count()
804 }
805
806 pub fn pending_count(&self) -> usize {
807 self.msgs.iter().filter(|m| m.pending()).count()
808 }
809
810 fn thread_has_unread(&self, root: usize) -> bool {
812 self.thread_root
813 .iter()
814 .enumerate()
815 .filter(|&(_, &r)| r == root)
816 .any(|(i, _)| !self.msgs[i].env.file.flags.seen)
817 }
818
819 fn first_unread_in_thread(&self, root: usize) -> Option<PathBuf> {
822 self.thread_root
823 .iter()
824 .enumerate()
825 .filter(|&(_, &r)| r == root)
826 .map(|(i, _)| i)
827 .find(|&i| !self.msgs[i].env.file.flags.seen)
828 .map(|i| self.msgs[i].env.file.path.clone())
829 }
830
831 pub fn thread_info(&self, mi: usize) -> (usize, Option<usize>) {
833 if self.sort != SortKey::Threads {
834 return (0, None);
835 }
836 let depth = self.thread_depth.get(mi).copied().unwrap_or(0);
837 if depth == 0 && self.collapsed.contains(&self.msgs[mi].env.file.path) {
838 let hidden = self
839 .thread_root
840 .iter()
841 .enumerate()
842 .filter(|&(j, &r)| r == mi && j != mi)
843 .count();
844 if hidden > 0 {
845 return (0, Some(hidden));
846 }
847 }
848 (depth, None)
849 }
850
851 pub fn select(&mut self, index: usize) {
852 if self.visible.is_empty() {
853 return;
854 }
855 self.sel = index.min(self.visible.len() - 1);
856 }
857
858 pub fn cur_mut(&mut self) -> Option<&mut Msg> {
859 let i = self.visible.get(self.sel).copied()?;
860 self.msgs.get_mut(i)
861 }
862
863 pub fn selected_path(&self) -> Option<PathBuf> {
864 self.visible
865 .get(self.sel)
866 .map(|&i| self.msgs[i].env.file.path.clone())
867 }
868
869 pub fn deny_readonly(&mut self) -> bool {
872 if self.read_only {
873 self.error("Mailbox is read-only.");
874 }
875 self.read_only
876 }
877
878 pub fn mark_read(&mut self) {
879 if self.read_only {
880 return;
881 }
882 if let Some(m) = self.cur_mut()
883 && (m.env.file.is_new || !m.env.file.flags.seen)
884 {
885 m.env.file.is_new = false;
886 m.env.file.flags.seen = true;
887 m.dirty = true;
888 }
889 }
890
891 pub fn scope(&self, position: pattern::Position) -> pattern::Scope<'_> {
894 pattern::Scope {
895 me: self.me(),
896 lists: &self.lists,
897 subscribed: &self.subscribed,
898 position,
899 thread: None,
900 }
901 }
902
903 pub fn scope_at(&self, position: pattern::Position, mi: usize) -> pattern::Scope<'_> {
907 let thread = (self.sort == SortKey::Threads && mi < self.msgs.len()).then(|| {
908 let root = self.thread_root.get(mi).copied().unwrap_or(mi);
909 pattern::ThreadView {
910 members: self.thread_members.get(root).map_or(&[][..], Vec::as_slice),
911 parent: self.thread_parent.get(mi).copied().flatten(),
912 children: self.thread_children.get(mi).map_or(&[][..], Vec::as_slice),
913 collapsed: self.thread_depth.get(mi) == Some(&0)
914 && self.collapsed.contains(&self.msgs[mi].env.file.path),
915 envs: self,
916 }
917 });
918 pattern::Scope {
919 thread,
920 ..self.scope(position)
921 }
922 }
923
924 pub fn me(&self) -> pattern::Me<'_> {
926 pattern::Me::new(&self.my_addresses, &self.alternates)
927 }
928
929 fn env_matches_at(
934 &self,
935 patterns: &[Pattern],
936 env: &Envelope,
937 pos: pattern::Position,
938 mi: usize,
939 ) -> bool {
940 pattern::matches_in(
941 patterns,
942 env,
943 self.scope_at(pos, mi),
944 Some(&|env: &Envelope, m: &pattern::Matcher| {
945 let set = self.body_hits.get(m.raw())?;
946 let uid = remote::uid_of(&env.file.path)?;
947 Some(set.contains(&uid))
948 }),
949 )
950 }
951
952 fn positions(&self) -> Vec<pattern::Position> {
957 let mut seen: HashMap<&str, usize> = HashMap::new();
958 for m in &self.msgs {
959 if let Some(id) = m.env.msg_id.as_deref() {
960 *seen.entry(id).or_default() += 1;
961 }
962 }
963 let mut numbers = vec![0usize; self.msgs.len()];
964 for (n, &mi) in self.visible.iter().enumerate() {
965 if let Some(slot) = numbers.get_mut(mi) {
966 *slot = n + 1;
967 }
968 }
969 let current = self
970 .visible
971 .get(self.sel)
972 .and_then(|&mi| numbers.get(mi).copied())
973 .unwrap_or(0);
974 let last = self.visible.len();
975 (0..self.msgs.len())
976 .map(|i| pattern::Position {
977 number: numbers[i],
978 current,
979 last,
980 duplicate: self.msgs[i]
981 .env
982 .msg_id
983 .as_deref()
984 .is_some_and(|id| seen.get(id).copied().unwrap_or(0) > 1),
985 })
986 .collect()
987 }
988
989 pub fn resolve_body_terms(&mut self, patterns: &[Pattern]) {
994 self.settle();
998 if self.imap.is_none() {
999 return;
1000 }
1001 for term in pattern::body_terms(patterns) {
1002 let simple = !term
1003 .chars()
1004 .any(|c| r".*+?[](){}|^$\".contains(c) || !c.is_ascii());
1005 if !simple || self.body_hits.contains_key(&term) {
1006 continue;
1007 }
1008 let Some(imap) = &mut self.imap else { return };
1009 if let Ok(Done::Uids(uids)) = imap.blocking(Job::SearchBody(term.clone())) {
1010 self.body_hits.insert(term, uids.into_iter().collect());
1011 }
1012 }
1013 }
1014
1015 pub fn rescan(&mut self) {
1018 let Ok(files) = maildir::scan(&self.dir) else {
1019 return;
1020 };
1021 let keep = self.selected_path();
1022 let mut old: std::collections::HashMap<PathBuf, Msg> = self
1023 .msgs
1024 .drain(..)
1025 .map(|m| (m.env.file.path.clone(), m))
1026 .collect();
1027 let mut arrived = 0usize;
1028 let mut arrivals: Vec<PathBuf> = Vec::new();
1029 for file in files {
1030 match old.remove(&file.path) {
1031 Some(prev) if prev.pending() => self.msgs.push(prev),
1032 Some(mut prev) => {
1033 prev.env.file = file;
1035 self.msgs.push(prev);
1036 }
1037 None => {
1038 if let Ok(env) = message::envelope(file) {
1039 if env.file.is_new {
1040 arrived += 1;
1041 arrivals.push(env.file.path.clone());
1042 }
1043 self.msgs.push(Msg {
1044 env,
1045 dirty: false,
1046 purge: false,
1047 });
1048 }
1049 }
1050 }
1051 }
1052 self.dir_mtimes = dir_mtimes(&self.dir);
1053 self.resort(keep.clone());
1054 if self.sort == SortKey::Threads
1057 && self.config.index.uncollapse_new.unwrap_or(true)
1058 && !self.collapsed.is_empty()
1059 {
1060 let mut unfolded = false;
1061 for path in &arrivals {
1062 if let Some(mi) = self.msgs.iter().position(|m| &m.env.file.path == path) {
1063 let root = self.thread_root.get(mi).copied().unwrap_or(mi);
1064 unfolded |= self.collapsed.remove(&self.msgs[root].env.file.path);
1065 }
1066 }
1067 if unfolded {
1068 self.rebuild_visible(keep);
1069 }
1070 }
1071 if arrived > 0 {
1072 let title = self.title.clone();
1073 self.notify(Notice::NewMail(format!("new mail in {title} (+{arrived})")));
1074 self.run_new_mail_command(&title, arrived);
1075 }
1076 }
1077
1078 fn run_new_mail_command(&self, mailbox: &str, count: usize) {
1081 let Some(cmd) = &self.config.mail.new_mail_command else {
1082 return;
1083 };
1084 let cmd = cmd
1085 .replace("%f", &format!("'{}'", mailbox.replace('\'', r"'\''")))
1086 .replace("%n", &count.to_string());
1087 let _ = Command::new("sh")
1088 .arg("-c")
1089 .arg(cmd)
1090 .stdout(Stdio::null())
1091 .stderr(Stdio::null())
1092 .spawn();
1093 }
1094
1095 pub fn check_new_mail(&mut self) {
1102 if self.pending.is_some() {
1105 return;
1106 }
1107 let backfilling = self.backfill.as_ref().is_some_and(|b| !b.done());
1108 if !backfilling && self.start(Job::CheckNew, Pending::CheckNew) {
1112 return;
1113 }
1114 self.check_local_mail();
1115 }
1116
1117 fn check_local_mail(&mut self) {
1120 self.maybe_backfill();
1121 if let Some(mbox) = &mut self.mbox {
1122 if let Err(err) = mbox.refresh() {
1124 self.error(format!("mbox: {err:#}"));
1125 }
1126 }
1127 self.check_other_mailboxes();
1128 self.requests.push(Request::MailboxesChanged);
1130 let check_new = self.config.mail.check_new.unwrap_or(true) || self.imap.is_some();
1134 if check_new && dir_mtimes(&self.dir) != self.dir_mtimes {
1135 self.rescan();
1136 }
1137 if self.deferred.is_none() {
1140 self.refresh_unseen();
1141 }
1142 }
1143
1144 pub fn poll_network(&mut self) {
1148 if let Some(line) = self.imap.as_mut().and_then(Imap::take_progress) {
1149 if !self.aborted {
1152 self.note(format!("{line} (Ctrl+G aborts)"));
1153 self.progress_noted = true;
1154 }
1155 }
1156 let Some(done) = self.imap.as_mut().and_then(Imap::collect) else {
1157 return;
1158 };
1159 if mem::take(&mut self.progress_noted) {
1162 self.clear_notice();
1163 }
1164 if let Some(pending) = self.pending.take() {
1165 self.resume(pending, done);
1166 }
1167 if self.pending.is_none()
1170 && let Some(again) = self.deferred.take()
1171 {
1172 self.run_again(again);
1173 }
1174 }
1175
1176 pub fn busy(&self) -> Option<&'static str> {
1178 self.imap.as_ref().and_then(Imap::busy)
1179 }
1180
1181 pub fn abort_network(&mut self) {
1185 let Some(what) = self.busy() else {
1186 return;
1187 };
1188 if let Some(imap) = &self.imap {
1189 imap.abort();
1190 }
1191 self.note(format!("aborted: {what}"));
1192 self.deferred = None;
1194 self.progress_noted = false;
1197 self.aborted = true;
1198 }
1199
1200 fn start(&mut self, job: Job, pending: Pending) -> bool {
1204 let Some(imap) = &mut self.imap else {
1205 return false;
1206 };
1207 let what = job.what();
1208 match imap.start(job) {
1209 Ok(()) => {
1210 self.note(format!("{what}... (Ctrl+G aborts)"));
1214 self.progress_noted = true;
1215 self.pending = Some(pending);
1216 true
1217 }
1218 Err(err) => {
1219 self.error(format!("imap: {err:#}"));
1220 false
1221 }
1222 }
1223 }
1224
1225 fn settle(&mut self) {
1228 while let Some(pending) = self.pending.take() {
1229 let Some(imap) = &mut self.imap else { break };
1230 let done = imap.wait();
1231 if mem::take(&mut self.progress_noted) {
1232 self.clear_notice();
1233 }
1234 self.resume(pending, done);
1235 }
1236 }
1237
1238 fn resume(&mut self, pending: Pending, done: Result<Done>) {
1239 if mem::take(&mut self.aborted) && done.is_err() {
1240 return;
1243 }
1244 match pending {
1245 Pending::CheckNew => {
1246 if let Err(err) = done {
1247 self.error(format!("imap: {err:#}"));
1248 }
1249 self.check_local_mail();
1250 }
1251 Pending::Sync { purge } => match done {
1252 Err(err) => self.error(format!("sync failed: {err:#}")),
1254 Ok(_) => self.finish_sync(purge),
1255 },
1256 Pending::Counts(specs) => {
1257 if let Ok(Done::Counts(counts)) = done {
1258 for (spec, count) in specs.into_iter().zip(counts) {
1259 self.unseen.insert(spec, count);
1260 }
1261 self.requests.push(Request::MailboxesChanged);
1262 }
1263 }
1264 Pending::Again(again) => match done {
1265 Err(err) => self.error(format!("cannot fetch message: {err:#}")),
1266 Ok(_) => self.run_again(again),
1267 },
1268 }
1269 }
1270
1271 fn run_again(&mut self, again: Again) {
1274 match again {
1275 Again::View => self.open_message(),
1276 Again::Copy {
1277 input,
1278 delete,
1279 tagged,
1280 decode,
1281 } => self.copy_message(&input, delete, tagged, decode),
1282 Again::Pipe { command, tagged } => self.pipe_message(&command, tagged),
1283 Again::Print { tagged } => self.print_current(tagged),
1284 Again::Bounce { to, tagged } => self.bounce_current(&to, tagged),
1285 }
1286 }
1287
1288 fn have_bodies(&mut self, paths: &[PathBuf], again: Again) -> bool {
1295 if self.imap.is_none() {
1296 return true;
1297 }
1298 let missing: Vec<PathBuf> = paths
1299 .iter()
1300 .filter(|path| remote::is_partial(path))
1301 .cloned()
1302 .collect();
1303 if missing.is_empty() {
1304 return true;
1305 }
1306 if self.pending.is_some() {
1310 self.deferred = Some(again);
1311 return false;
1312 }
1313 !self.start(Job::FetchBodies(missing), Pending::Again(again))
1314 }
1315
1316 fn target_paths(&self, tagged: bool) -> Vec<PathBuf> {
1319 self.op_targets(tagged)
1320 .into_iter()
1321 .map(|i| self.msgs[i].env.file.path.clone())
1322 .collect()
1323 }
1324
1325 pub fn idle_kick(&self) -> bool {
1328 self.idle.as_ref().is_some_and(|w| w.take_changed())
1329 }
1330
1331 fn check_other_mailboxes(&mut self) {
1334 let mut grew: Vec<String> = Vec::new();
1335 for spec in self.config.mail.mailboxes.clone() {
1336 if spec.starts_with("imap:") || spec == self.title {
1337 continue; }
1339 let dir = expand_tilde(&spec);
1340 if dir == self.dir || !dir.join("new").is_dir() {
1341 continue;
1342 }
1343 let count = maildir::new_count(&dir);
1344 let prev = self.mailbox_new.insert(spec.clone(), count);
1345 if let Some(p) = prev.filter(|&p| count > p) {
1346 self.run_new_mail_command(&spec, count - p);
1347 grew.push(spec);
1348 } else if !self.config.mail.mail_check_recent.unwrap_or(true) {
1349 if count == 0 {
1353 self.mailbox_announced.remove(&spec);
1354 } else if self.mailbox_announced.insert(spec.clone()) {
1355 grew.push(spec);
1356 }
1357 }
1358 }
1359 if !grew.is_empty() && self.notice().is_none() {
1361 self.notify(Notice::NewMail(format!("new mail in {}", grew.join(", "))));
1362 }
1363 }
1364
1365 pub fn maybe_backfill(&mut self) {
1368 if self.backfill.as_ref().is_some_and(|b| !b.done()) {
1369 return;
1370 }
1371 self.backfill = None;
1372 let Some(imap) = &mut self.imap else {
1373 return;
1374 };
1375 if imap.facts.pending_backfill.is_empty() {
1376 return;
1377 }
1378 let uids = imap.take_backfill();
1379 let facts = imap.facts.clone();
1380 let Ok(password) = account_password(&facts.account) else {
1381 return;
1382 };
1383 let count = uids.len();
1384 self.backfill = Some(remote::backfill(
1385 &facts.account,
1386 &facts.mailbox,
1387 &password,
1388 facts.cache,
1389 uids,
1390 ));
1391 self.note(format!(
1392 "loading {count} older message(s) in the background"
1393 ));
1394 }
1395
1396 pub fn error_history(&self) -> Vec<String> {
1400 self.error_history.iter().cloned().collect()
1401 }
1402
1403 pub fn next_unread_mailbox(&self) -> Option<String> {
1408 let boxes = &self.config.mail.mailboxes;
1409 let start = boxes
1410 .iter()
1411 .position(|spec| *spec == self.title || expand_tilde(spec) == self.dir)
1412 .map(|i| i + 1)
1413 .unwrap_or(0);
1414 (0..boxes.len())
1415 .map(|k| &boxes[(start + k) % boxes.len()])
1416 .filter(|spec| **spec != self.title && expand_tilde(spec) != self.dir)
1417 .find(|spec| {
1418 if spec.starts_with("imap:") {
1419 self.unseen.get(*spec).copied().unwrap_or(0) > 0
1420 } else {
1421 maildir::new_count(&expand_tilde(spec)) > 0
1422 }
1423 })
1424 .cloned()
1425 }
1426
1427 pub fn delete_rules(&self) -> DeleteRules {
1430 DeleteRules {
1431 flag_safe: self.config.mail.flag_safe,
1432 untag: self.config.mail.delete_untag.unwrap_or(true),
1433 }
1434 }
1435
1436 pub fn each_tagged(&mut self, what: &str, f: impl Fn(&mut Msg)) {
1437 let tagged: Vec<usize> = (0..self.msgs.len())
1438 .filter(|&i| self.msgs[i].env.tagged)
1439 .collect();
1440 self.push_undo(what, &tagged);
1441 for &i in &tagged {
1442 let m = &mut self.msgs[i];
1443 f(m);
1444 m.dirty = true;
1445 }
1446 self.note(format!("applied to {} tagged message(s)", tagged.len()));
1447 }
1448
1449 pub fn op_targets(&self, tagged: bool) -> Vec<usize> {
1457 if tagged {
1458 return (0..self.msgs.len())
1459 .filter(|&i| self.msgs[i].env.tagged)
1460 .collect();
1461 }
1462 self.visible.get(self.sel).copied().into_iter().collect()
1463 }
1464
1465 pub fn push_undo(&mut self, what: &str, indices: &[usize]) {
1468 if indices.is_empty() {
1469 return;
1470 }
1471 let marks = indices.iter().map(|&i| self.mark(i)).collect();
1472 self.push_undo_step(UndoStep {
1473 what: what.to_string(),
1474 marks,
1475 sel: self.selected_path(),
1476 created: Vec::new(),
1477 note: None,
1478 rewritten: Vec::new(),
1479 });
1480 }
1481
1482 pub fn push_undo_step(&mut self, step: UndoStep) {
1483 self.undo.push(step);
1484 while self.undo.len() > UNDO_MAX_STEPS
1486 || (self.undo.len() > 1
1487 && self.undo.iter().map(|s| s.marks.len()).sum::<usize>() > UNDO_MAX_MARKS)
1488 {
1489 self.undo.remove(0);
1490 }
1491 }
1492
1493 pub fn mark(&self, i: usize) -> MsgMark {
1494 let m = &self.msgs[i];
1495 MsgMark {
1496 path: m.env.file.path.clone(),
1497 flags: m.env.file.flags,
1498 is_new: m.env.file.is_new,
1499 tagged: m.env.tagged,
1500 dirty: m.dirty,
1501 }
1502 }
1503
1504 pub fn undo_last(&mut self) {
1510 let Some(step) = self.undo.pop() else {
1511 self.note("nothing to undo");
1512 return;
1513 };
1514 let mut restored = 0usize;
1515 let mut rewrite_failed = Vec::new();
1516 let mut resort = false;
1517 for (path, bytes) in &step.rewritten {
1518 let at = self.msgs.iter().position(|m| &m.env.file.path == path);
1519 let put_back = std::fs::write(path, bytes)
1520 .map_err(|err| anyhow::anyhow!(err))
1521 .and_then(|()| match at {
1522 Some(i) => self.reread(i, bytes.len() as u64),
1523 None => Ok(()),
1524 });
1525 match put_back {
1526 Ok(()) => resort = true,
1527 Err(err) => rewrite_failed.push(format!("{}: {err}", path.display())),
1528 }
1529 }
1530 if resort {
1531 let keep = step.sel.clone();
1532 self.resort(keep);
1533 }
1534 let by_path: HashMap<&Path, usize> = self
1535 .msgs
1536 .iter()
1537 .enumerate()
1538 .map(|(i, m)| (m.env.file.path.as_path(), i))
1539 .collect();
1540 let mut wanted: Vec<(usize, MsgMark)> = Vec::new();
1541 for mark in &step.marks {
1542 if let Some(&i) = by_path.get(mark.path.as_path()) {
1543 wanted.push((i, mark.clone()));
1544 }
1545 }
1546 for (i, mark) in wanted {
1547 let m = &mut self.msgs[i];
1548 m.env.file.flags = mark.flags;
1549 m.env.file.is_new = mark.is_new;
1550 m.env.tagged = mark.tagged;
1551 m.dirty = mark.dirty;
1552 restored += 1;
1553 }
1554 let mut failed = rewrite_failed;
1555 for path in &step.created {
1556 if let Err(err) = std::fs::remove_file(path) {
1557 failed.push(format!("{}: {err}", path.display()));
1558 }
1559 }
1560 if let Some(vi) = step.sel.and_then(|p| {
1561 self.visible
1562 .iter()
1563 .position(|&i| self.msgs[i].env.file.path == p)
1564 }) {
1565 self.sel = vi;
1566 }
1567 let mut note = format!("undone: {} ({restored} message(s))", step.what);
1568 if let Some(extra) = &step.note {
1569 note += &format!("; {extra}");
1570 }
1571 if !failed.is_empty() {
1572 self.error(format!("{note}; could not remove {}", failed.join("; ")));
1573 } else {
1574 self.note(note);
1575 }
1576 }
1577
1578 pub fn message_bytes(&mut self, i: usize) -> Option<Vec<u8>> {
1581 self.settle();
1585 let path = self.msgs.get(i)?.env.file.path.clone();
1586 if remote::is_partial(&path)
1587 && let Some(imap) = &mut self.imap
1588 && let Err(err) = imap.blocking(Job::FetchBodies(vec![path.clone()]))
1589 {
1590 self.error(format!("cannot fetch message: {err:#}"));
1591 return None;
1592 }
1593 match std::fs::read(&path) {
1594 Ok(bytes) => Some(bytes),
1595 Err(err) => {
1596 self.error(format!("cannot read message: {err}"));
1597 None
1598 }
1599 }
1600 }
1601
1602 pub fn full_message_bytes(&mut self) -> Option<Vec<u8>> {
1603 let &i = self.visible.get(self.sel)?;
1604 self.message_bytes(i)
1605 }
1606
1607 pub fn op_bytes(&mut self, tagged: bool) -> Option<Vec<u8>> {
1609 let mut out = Vec::new();
1610 for i in self.op_targets(tagged) {
1611 let bytes = self.message_bytes(i)?;
1612 out.extend_from_slice(&bytes);
1613 if !bytes.ends_with(b"\n") {
1614 out.push(b'\n');
1615 }
1616 }
1617 Some(out)
1618 }
1619
1620 pub fn rebuild_visible(&mut self, keep: Option<PathBuf>) {
1623 let mut visible = Vec::with_capacity(self.msgs.len());
1624 let positions = self.positions();
1625 for (i, pos) in positions.iter().enumerate() {
1626 let limit_ok = match &self.limit {
1627 Some((_, patterns)) => self.env_matches_at(patterns, &self.msgs[i].env, *pos, i),
1628 None => true,
1629 };
1630 if !limit_ok {
1631 continue;
1632 }
1633 if self.limit.is_none()
1634 && self.sort == SortKey::Threads
1635 && self.thread_depth.get(i).copied().unwrap_or(0) > 0
1636 {
1637 let root = self.thread_root.get(i).copied().unwrap_or(i);
1638 if self.collapsed.contains(&self.msgs[root].env.file.path) {
1639 continue;
1640 }
1641 }
1642 visible.push(i);
1643 }
1644 self.visible = visible;
1645 self.sel = keep
1646 .and_then(|p| {
1647 self.visible
1648 .iter()
1649 .position(|&i| self.msgs[i].env.file.path == p)
1650 })
1651 .unwrap_or_else(|| self.visible.len().saturating_sub(1));
1652 }
1653
1654 pub fn apply_sort(&mut self) {
1655 let keep = self.selected_path();
1656 self.resort(keep);
1657 }
1658
1659 fn resort(&mut self, keep: Option<PathBuf>) {
1660 if self.sort == SortKey::Threads {
1661 let order = self
1664 .config
1665 .index
1666 .sort_aux
1667 .as_deref()
1668 .map(thread::ThreadOrder::parse)
1669 .unwrap_or_default();
1670 let items = {
1675 let fallback = (!self.config.index.strict_threads.unwrap_or(false)).then(|| {
1676 thread::SubjectFallback {
1677 reply_re: &self.reply_re,
1678 sort_re: self.config.index.sort_re.unwrap_or(true),
1679 }
1680 });
1681 let envs: Vec<&Envelope> = self.msgs.iter().map(|m| &m.env).collect();
1682 thread::thread_with(&envs, order, fallback.as_ref())
1683 };
1684 let mut old: Vec<Option<Msg>> = self.msgs.drain(..).map(Some).collect();
1685 let mut new_pos = vec![0usize; old.len()];
1686 for (pos, item) in items.iter().enumerate() {
1687 new_pos[item.index] = pos;
1688 }
1689 self.msgs = items
1690 .iter()
1691 .map(|item| {
1692 old[item.index]
1693 .take()
1694 .expect("thread order is a permutation")
1695 })
1696 .collect();
1697 self.thread_depth = items.iter().map(|item| item.depth).collect();
1698 self.thread_root = items.iter().map(|item| new_pos[item.root]).collect();
1699 self.thread_pseudo = items.iter().map(|item| item.pseudo).collect();
1700 self.sort_rev = false;
1701 self.index_threads();
1702 } else {
1703 let (sort, rev) = (self.sort, self.sort_rev);
1704 self.msgs.sort_by(|a, b| {
1705 let ord = match sort {
1706 SortKey::Date => a.env.date.cmp(&b.env.date),
1707 SortKey::From => a.env.from.to_lowercase().cmp(&b.env.from.to_lowercase()),
1708 SortKey::Subject => {
1709 subject_key(&a.env.subject).cmp(&subject_key(&b.env.subject))
1710 }
1711 SortKey::Size => a.env.file.size.cmp(&b.env.file.size),
1712 SortKey::Label => {
1713 let key = |e: &Envelope| {
1715 let l = e.label.clone().unwrap_or_default().to_lowercase();
1716 (l.is_empty(), l)
1717 };
1718 key(&a.env).cmp(&key(&b.env))
1719 }
1720 SortKey::To => {
1721 let first_to =
1722 |e: &Envelope| e.to.first().cloned().unwrap_or_default().to_lowercase();
1723 first_to(&a.env).cmp(&first_to(&b.env))
1724 }
1725 SortKey::Unsorted => a
1726 .env
1727 .file
1728 .path
1729 .file_name()
1730 .cmp(&b.env.file.path.file_name()),
1731 SortKey::Threads => unreachable!(),
1732 };
1733 if rev { ord.reverse() } else { ord }
1734 });
1735 self.thread_depth = vec![0; self.msgs.len()];
1736 self.thread_root = (0..self.msgs.len()).collect();
1737 self.thread_pseudo = vec![false; self.msgs.len()];
1738 self.index_threads();
1739 }
1740 self.rebuild_visible(keep);
1741 }
1742
1743 fn index_threads(&mut self) {
1746 let n = self.msgs.len();
1747 let mut parent = vec![None; n];
1748 let mut children = vec![Vec::new(); n];
1749 let mut members: Vec<Vec<usize>> = vec![Vec::new(); n];
1750 let mut last_at_depth: Vec<usize> = Vec::new();
1751 #[allow(clippy::needless_range_loop)]
1752 for i in 0..n {
1753 let depth = self.thread_depth.get(i).copied().unwrap_or(0);
1754 last_at_depth.truncate(depth);
1755 if let Some(&p) = last_at_depth.last() {
1756 parent[i] = Some(p);
1757 children[p].push(i);
1758 }
1759 last_at_depth.push(i);
1760 let root = self.thread_root.get(i).copied().unwrap_or(i);
1761 members[root].push(i);
1762 }
1763 self.thread_parent = parent;
1764 self.thread_children = children;
1765 self.thread_members = members;
1766 }
1767
1768 pub fn toggle_collapse(&mut self, all: bool) {
1769 if self.sort != SortKey::Threads {
1770 self.error("folding needs thread sort (o t)");
1771 return;
1772 }
1773 let mut keep;
1774 if all {
1775 keep = self.selected_path();
1776 if self.collapsed.is_empty() {
1777 let fold_unread = self.config.index.collapse_unread.unwrap_or(true);
1780 self.collapsed = (0..self.msgs.len())
1781 .filter(|&i| self.thread_depth.get(i) == Some(&0))
1782 .filter(|&i| fold_unread || !self.thread_has_unread(i))
1783 .map(|i| self.msgs[i].env.file.path.clone())
1784 .collect();
1785 } else {
1786 self.collapsed.clear();
1787 }
1788 } else {
1789 let Some(&mi) = self.visible.get(self.sel) else {
1790 return;
1791 };
1792 let root = self.thread_root.get(mi).copied().unwrap_or(mi);
1793 let path = self.msgs[root].env.file.path.clone();
1794 let unfolding = self.collapsed.remove(&path);
1795 if !unfolding {
1796 self.collapsed.insert(path.clone());
1797 }
1798 keep = Some(path);
1799 if unfolding && self.config.index.uncollapse_jump {
1801 keep = self.first_unread_in_thread(root).or(keep);
1802 }
1803 }
1804 self.rebuild_visible(keep);
1805 }
1806
1807 pub fn step_message(&self, forward: bool, skip_deleted: bool) -> Option<usize> {
1810 let mut pos = self.sel;
1811 loop {
1812 pos = if forward {
1813 pos + 1
1814 } else {
1815 pos.checked_sub(1)?
1816 };
1817 let &i = self.visible.get(pos)?;
1818 if !skip_deleted || !self.msgs[i].env.file.flags.deleted {
1819 return Some(pos);
1820 }
1821 }
1822 }
1823
1824 fn thread_targets(&mut self, sub: bool) -> Option<Vec<usize>> {
1832 if self.sort != SortKey::Threads {
1833 self.error("thread operations need thread sort (o t)");
1834 return None;
1835 }
1836 let &mi = self.visible.get(self.sel)?;
1837 if !sub {
1838 let root = self.thread_root.get(mi).copied().unwrap_or(mi);
1839 return Some(
1840 (0..self.msgs.len())
1841 .filter(|&i| self.thread_root.get(i).copied().unwrap_or(i) == root)
1842 .collect(),
1843 );
1844 }
1845 let depth = self.thread_depth.get(mi).copied().unwrap_or(0);
1848 let mut out = vec![mi];
1849 for i in (mi + 1)..self.msgs.len() {
1850 if self.thread_depth.get(i).copied().unwrap_or(0) <= depth {
1851 break;
1852 }
1853 out.push(i);
1854 }
1855 Some(out)
1856 }
1857
1858 pub fn thread_mark(&mut self, sub: bool, op: ThreadOp) {
1862 if op != ThreadOp::Tag && self.deny_readonly() {
1863 return;
1864 }
1865 let Some(&mi) = self.visible.get(self.sel) else {
1866 return;
1867 };
1868 let Some(targets) = self.thread_targets(sub) else {
1869 return;
1870 };
1871 let scope = if sub { "subthread" } else { "thread" };
1872 let (what, verb) = match op {
1873 ThreadOp::Delete => (format!("delete {scope}"), "deleted"),
1874 ThreadOp::Undelete => (format!("undelete {scope}"), "undeleted"),
1875 ThreadOp::Read => (format!("read {scope}"), "marked read"),
1876 ThreadOp::Tag if self.msgs[mi].env.tagged => (format!("untag {scope}"), "untagged"),
1879 ThreadOp::Tag => (format!("tag {scope}"), "tagged"),
1880 };
1881 self.push_undo(&what, &targets);
1882 let want_tag = !self.msgs[mi].env.tagged;
1883 let rules = self.delete_rules();
1884 for &i in &targets {
1885 match op {
1886 ThreadOp::Delete => {
1887 if rules.mark(&mut self.msgs[i]) {
1888 self.msgs[i].dirty = true;
1889 }
1890 }
1891 ThreadOp::Undelete => {
1892 self.msgs[i].env.file.flags.deleted = false;
1893 self.msgs[i].dirty = true;
1894 }
1895 ThreadOp::Tag => self.msgs[i].env.tagged = want_tag,
1896 ThreadOp::Read => {
1897 let m = &mut self.msgs[i];
1898 if !m.env.file.flags.seen || m.env.file.is_new {
1899 m.env.file.flags.seen = true;
1900 m.env.file.is_new = false;
1901 m.dirty = true;
1902 }
1903 }
1904 }
1905 }
1906 self.note(format!("{} {verb}", targets.len()));
1907 if op == ThreadOp::Delete
1910 && let Some(pos) = self.step_message(true, true)
1911 {
1912 self.sel = pos;
1913 }
1914 }
1915
1916 pub fn jump_parent(&mut self, root: bool) {
1920 if self.sort != SortKey::Threads {
1921 self.error("thread operations need thread sort (o t)");
1922 return;
1923 }
1924 let Some(&mi) = self.visible.get(self.sel) else {
1925 return;
1926 };
1927 let target = if root {
1928 self.thread_root.get(mi).copied().filter(|&r| r != mi)
1929 } else {
1930 self.thread_parent.get(mi).copied().flatten()
1931 };
1932 let Some(target) = target else {
1933 self.error(if root {
1934 "already the thread root"
1935 } else {
1936 "no parent message"
1937 });
1938 return;
1939 };
1940 let tr = self.thread_root.get(target).copied().unwrap_or(target);
1941 let at = self
1942 .visible
1943 .iter()
1944 .position(|&i| i == target)
1945 .or_else(|| self.visible.iter().position(|&i| i == tr));
1946 match at {
1947 Some(vi) => self.select(vi),
1948 None => self.error("the parent is not in the current limit"),
1949 }
1950 }
1951
1952 fn can_rewrite(&mut self) -> bool {
1957 if self.deny_readonly() {
1958 return false;
1959 }
1960 if self.sort != SortKey::Threads {
1961 self.error("thread operations need thread sort (o t)");
1962 return false;
1963 }
1964 if self.imap.is_some() || self.mbox.is_some() {
1965 self.error("rewriting a message's threading is for local maildirs only");
1966 return false;
1967 }
1968 true
1969 }
1970
1971 fn rewrite_thread(
1977 &mut self,
1978 i: usize,
1979 in_reply_to: Option<&str>,
1980 references: &[String],
1981 broken: bool,
1982 ) -> Result<Vec<u8>> {
1983 let path = self.msgs[i].env.file.path.clone();
1984 let old = std::fs::read(&path).with_context(|| format!("reading {}", path.display()))?;
1985 let new = message::with_thread_headers(&old, in_reply_to, references, broken);
1986 std::fs::write(&path, &new).with_context(|| format!("writing {}", path.display()))?;
1987 self.reread(i, new.len() as u64)?;
1988 Ok(old)
1989 }
1990
1991 fn reread(&mut self, i: usize, size: u64) -> Result<()> {
1994 let mut file = self.msgs[i].env.file.clone();
1995 file.size = size;
1996 let tagged = self.msgs[i].env.tagged;
1997 let mut env = message::envelope(file)?;
1998 env.tagged = tagged;
1999 self.msgs[i].env = env;
2000 Ok(())
2001 }
2002
2003 pub fn break_thread(&mut self) {
2008 let Some(&mi) = self.visible.get(self.sel) else {
2009 return;
2010 };
2011 if !self.can_rewrite() {
2012 return;
2013 }
2014 if self.msgs[mi].env.references.is_empty() && !self.subject_threaded(mi) {
2018 self.note("already a thread of its own");
2019 return;
2020 }
2021 let path = self.msgs[mi].env.file.path.clone();
2022 let mut step = UndoStep {
2023 what: "break thread".into(),
2024 marks: vec![self.mark(mi)],
2025 sel: Some(path.clone()),
2026 created: Vec::new(),
2027 note: None,
2028 rewritten: Vec::new(),
2029 };
2030 match self.rewrite_thread(mi, None, &[], true) {
2031 Ok(old) => {
2032 step.rewritten.push((path.clone(), old));
2033 self.push_undo_step(step);
2034 self.resort(Some(path));
2035 self.note("thread broken");
2036 }
2037 Err(err) => self.error(format!("break thread: {err:#}")),
2038 }
2039 }
2040
2041 pub fn link_threads(&mut self) {
2046 let Some(&mi) = self.visible.get(self.sel) else {
2047 return;
2048 };
2049 if !self.can_rewrite() {
2050 return;
2051 }
2052 let Some(parent_id) = self.msgs[mi].env.msg_id.clone() else {
2053 self.error("no Message-ID to link to");
2054 return;
2055 };
2056 let kids: Vec<usize> = (0..self.msgs.len())
2057 .filter(|&i| i != mi && self.msgs[i].env.tagged)
2058 .collect();
2059 if kids.is_empty() {
2060 self.error("first tag the message(s) to link");
2061 return;
2062 }
2063 let keep = self.msgs[mi].env.file.path.clone();
2064 let mut step = UndoStep {
2065 what: "link threads".into(),
2066 marks: kids.iter().map(|&i| self.mark(i)).collect(),
2067 sel: Some(keep.clone()),
2068 created: Vec::new(),
2069 note: None,
2070 rewritten: Vec::new(),
2071 };
2072 let mut linked = 0usize;
2073 for &i in &kids {
2074 let path = self.msgs[i].env.file.path.clone();
2075 match self.rewrite_thread(i, Some(&parent_id), &[], false) {
2076 Ok(old) => {
2077 step.rewritten.push((path, old));
2078 self.msgs[i].env.tagged = false;
2079 linked += 1;
2080 }
2081 Err(err) => self.error(format!("link threads: {err:#}")),
2082 }
2083 }
2084 if linked > 0 {
2085 self.push_undo_step(step);
2086 self.resort(Some(keep));
2087 self.note(format!("{linked} linked"));
2088 }
2089 }
2090
2091 pub fn edit_label(&mut self, value: &str, tagged: bool) {
2096 if !self.can_rewrite_here() {
2097 return;
2098 }
2099 let targets = self.op_targets(tagged);
2100 if targets.is_empty() {
2101 return;
2102 }
2103 let value = value.trim();
2104 let new = (!value.is_empty()).then(|| value.to_string());
2105 let keep = self.selected_path();
2106 let mut step = UndoStep {
2107 what: "edit label".into(),
2108 marks: targets.iter().map(|&i| self.mark(i)).collect(),
2109 sel: keep.clone(),
2110 created: Vec::new(),
2111 note: None,
2112 rewritten: Vec::new(),
2113 };
2114 let mut done = 0usize;
2115 for &i in &targets {
2116 let path = self.msgs[i].env.file.path.clone();
2117 let old = match std::fs::read(&path) {
2118 Ok(b) => b,
2119 Err(err) => {
2120 self.error(format!("edit label: {err}"));
2121 continue;
2122 }
2123 };
2124 let bytes = message::with_header(&old, "X-Label", new.as_deref());
2125 if let Err(err) = std::fs::write(&path, &bytes) {
2126 self.error(format!("edit label: {err}"));
2127 continue;
2128 }
2129 step.rewritten.push((path, old));
2130 let _ = self.reread(i, bytes.len() as u64);
2131 done += 1;
2132 }
2133 if done > 0 {
2134 self.push_undo_step(step);
2135 self.resort(keep);
2136 self.note(match new {
2137 Some(_) => format!("labelled {done} message(s)"),
2138 None => format!("label cleared on {done} message(s)"),
2139 });
2140 }
2141 }
2142
2143 fn can_rewrite_here(&mut self) -> bool {
2146 if self.deny_readonly() {
2147 return false;
2148 }
2149 if self.imap.is_some() || self.mbox.is_some() {
2150 self.error("editing a label is for local maildirs only");
2151 return false;
2152 }
2153 true
2154 }
2155
2156 pub fn subject_threaded(&self, mi: usize) -> bool {
2160 self.thread_pseudo.get(mi).copied().unwrap_or(false)
2161 }
2162
2163 pub fn subject_hidden(&self, mi: usize) -> bool {
2168 if !self.config.index.hide_thread_subject.unwrap_or(false) {
2169 return false;
2170 }
2171 let Some(parent) = self.thread_parent.get(mi).copied().flatten() else {
2172 return false;
2173 };
2174 match (self.msgs.get(mi), self.msgs.get(parent)) {
2175 (Some(m), Some(p)) => subject_key(&m.env.subject) == subject_key(&p.env.subject),
2176 _ => false,
2177 }
2178 }
2179
2180 pub fn toggle_write(&mut self) {
2184 if self.read_only_session {
2185 self.error("mailbox is read-only for the whole session (-R)");
2186 return;
2187 }
2188 self.read_only = !self.read_only;
2189 if self.read_only {
2190 self.note("mailbox marked read-only");
2191 } else {
2192 self.note("mailbox marked writable");
2193 }
2194 }
2195
2196 pub fn show_limit(&mut self) {
2199 match &self.limit {
2200 Some((raw, _)) => self.note(format!("limit: {raw}")),
2201 None => self.note("no limit pattern (all messages shown)"),
2202 }
2203 }
2204
2205 pub fn reply_subject(&self, orig: &str) -> String {
2207 compose::reply_subject(orig, &self.reply_re)
2208 }
2209
2210 pub fn jump_thread(&mut self, forward: bool) {
2213 if self.sort != SortKey::Threads {
2214 self.error("thread operations need thread sort (o t)");
2215 return;
2216 }
2217 for (vi, wrapped) in wrap_order(self.visible.len(), self.sel, forward) {
2218 let mi = self.visible[vi];
2219 if self.thread_depth.get(mi).copied().unwrap_or(0) == 0 {
2220 if wrapped {
2221 self.note("wrapped around");
2222 }
2223 self.select(vi);
2224 return;
2225 }
2226 }
2227 self.error("no other thread");
2228 }
2229
2230 pub fn jump_new(&mut self, forward: bool) {
2231 let n = self.visible.len();
2232 if n == 0 {
2233 return;
2234 }
2235 for (vi, wrapped) in wrap_order(n, self.sel, forward) {
2236 let m = &self.msgs[self.visible[vi]];
2237 if m.env.file.is_new || !m.env.file.flags.seen {
2238 if wrapped {
2239 self.note("search wrapped");
2240 }
2241 self.select(vi);
2242 return;
2243 }
2244 }
2245 self.error("no new or unread messages");
2246 }
2247
2248 pub fn compile_search(&self, input: &str) -> Result<Vec<Pattern>, String> {
2256 pattern::parse(&self.expand_simple(input))
2257 }
2258
2259 fn expand_simple(&self, input: &str) -> String {
2260 let word = input.trim();
2261 if word.contains('~') || word.split_whitespace().count() != 1 {
2262 return input.to_string();
2263 }
2264 let template = self
2265 .config
2266 .mail
2267 .simple_search
2268 .as_deref()
2269 .unwrap_or("~f %s | ~s %s");
2270 template.replace("%s", word)
2271 }
2272
2273 pub fn apply_pattern(&mut self, input: &str, verb: &'static str, f: impl Fn(&mut Msg)) {
2274 if input.is_empty() {
2275 return;
2276 }
2277 let patterns = match self.compile_search(input) {
2278 Ok(p) => p,
2279 Err(err) => {
2280 self.error(format!("bad pattern: {err}"));
2281 return;
2282 }
2283 };
2284 self.resolve_body_terms(&patterns);
2285 let positions = self.positions();
2286 let mut hits = Vec::new();
2287 for (i, pos) in positions.iter().enumerate() {
2288 let in_limit = match &self.limit {
2289 Some((_, l)) => self.env_matches_at(l, &self.msgs[i].env, *pos, i),
2290 None => true,
2291 };
2292 if in_limit && self.env_matches_at(&patterns, &self.msgs[i].env, *pos, i) {
2293 hits.push(i);
2294 }
2295 }
2296 self.push_undo(&format!("{verb} by pattern"), &hits);
2297 for &i in &hits {
2298 f(&mut self.msgs[i]);
2299 }
2300 self.note(format!("{} {verb}", hits.len()));
2301 }
2302
2303 pub fn search_next(&mut self) {
2304 let Some(patterns) = self.last_search.clone() else {
2305 self.error("no search pattern (use /)");
2306 return;
2307 };
2308 if self.visible.is_empty() {
2309 return;
2310 }
2311 let wrap = self.config.mail.wrap_search.unwrap_or(true);
2314 let positions = self.positions();
2315 for (vi, wrapped) in wrap_order(self.visible.len(), self.sel, !self.search_rev) {
2316 if wrapped && !wrap {
2317 break;
2318 }
2319 let mi = self.visible[vi];
2320 if self.env_matches_at(&patterns, &self.msgs[mi].env, positions[mi], mi) {
2321 if wrapped {
2322 self.note("search wrapped");
2323 }
2324 self.sel = vi;
2325 return;
2326 }
2327 }
2328 self.error("not found");
2329 }
2330
2331 fn trash_deleted(&mut self, trash: &str) -> Result<()> {
2335 self.settle();
2339 let deleted: Vec<PathBuf> = self
2341 .msgs
2342 .iter()
2343 .filter(|m| m.env.file.flags.deleted && !m.purge)
2344 .map(|m| m.env.file.path.clone())
2345 .collect();
2346 match (remote::parse_spec(trash), &mut self.imap) {
2347 (Some((account, folder)), Some(imap)) if imap.facts.account.name == account => imap
2348 .blocking(Job::CopyToFolder {
2349 paths: deleted,
2350 mailbox: folder.to_string(),
2351 })
2352 .map(|_| ()),
2353 (Some(_), _) => anyhow::bail!("trash must be a folder of the open account"),
2354 (None, Some(_)) => {
2355 anyhow::bail!("an IMAP mailbox needs an imap:account/folder trash")
2356 }
2357 (None, None) => {
2358 let dir = expand_tilde(trash);
2359 maildir::create(&dir)?;
2360 for m in self
2361 .msgs
2362 .iter()
2363 .filter(|m| m.env.file.flags.deleted && !m.purge)
2364 {
2365 let bytes = std::fs::read(&m.env.file.path)?;
2366 let mut flags = m.env.file.flags;
2367 flags.deleted = false;
2368 maildir::deliver(&dir, &bytes, flags)?;
2369 }
2370 Ok(())
2371 }
2372 }
2373 }
2374
2375 pub fn sync(&mut self, purge: bool) {
2378 if self.deny_readonly() {
2379 return;
2380 }
2381 if purge
2384 && self.deleted_count() > 0
2385 && let Some(trash) = self.config.mail.trash.clone()
2386 && trash != self.title
2387 && expand_tilde(&trash) != self.dir
2388 && let Err(err) = self.trash_deleted(&trash)
2389 {
2390 self.error(format!("trash failed: {err:#}; nothing purged"));
2391 return;
2392 }
2393 if self.imap.is_some() {
2394 let mut deletes: Vec<PathBuf> = Vec::new();
2395 let mut flags: Vec<(PathBuf, maildir::Flags)> = Vec::new();
2396 for m in &self.msgs {
2397 if m.env.file.flags.deleted {
2398 if purge {
2399 deletes.push(m.env.file.path.clone());
2400 }
2401 } else if m.dirty {
2402 flags.push((m.env.file.path.clone(), m.env.file.flags));
2403 }
2404 }
2405 if self.start(Job::Sync { flags, deletes }, Pending::Sync { purge }) {
2409 return;
2410 }
2411 }
2412 self.finish_sync(purge);
2413 }
2414
2415 fn finish_sync(&mut self, purge: bool) {
2418 if let Some(mbox) = &mut self.mbox {
2419 let mut state: HashMap<String, Option<(maildir::Flags, bool)>> = HashMap::new();
2422 for m in &self.msgs {
2423 let Some(id) = mbox::id_of(&m.env.file.path) else {
2424 continue;
2425 };
2426 if m.env.file.flags.deleted && purge {
2427 state.insert(id, None);
2428 } else if m.pending() {
2429 let is_new = m.env.file.is_new && !m.dirty;
2430 state.insert(id, Some((m.env.file.flags, is_new)));
2431 }
2432 }
2433 if let Err(err) = mbox.write_back(&state) {
2434 self.error(format!("sync failed: {err:#}"));
2436 return;
2437 }
2438 }
2439 let keep = self.selected_path();
2440 let mut removed = 0usize;
2441 let mut saved = 0usize;
2442 let mut errors: Vec<String> = Vec::new();
2443 let trash = self.config.mail.maildir_trash && self.imap.is_none() && self.mbox.is_none();
2447 self.msgs.retain_mut(|m| {
2448 if m.env.file.flags.deleted {
2449 if !purge {
2450 return true; }
2452 if trash {
2453 if m.dirty {
2454 match maildir::store_flags(&m.env.file) {
2455 Ok(path) => {
2456 m.env.file.path = path;
2457 m.env.file.is_new = false;
2458 m.dirty = false;
2459 saved += 1;
2460 }
2461 Err(err) => errors.push(err.to_string()),
2462 }
2463 }
2464 return true;
2465 }
2466 match maildir::remove(&m.env.file) {
2467 Ok(()) => {
2468 removed += 1;
2469 false
2470 }
2471 Err(err) => {
2472 errors.push(err.to_string());
2473 true
2474 }
2475 }
2476 } else {
2477 if m.dirty {
2478 match maildir::store_flags(&m.env.file) {
2479 Ok(path) => {
2480 m.env.file.path = path;
2481 m.env.file.is_new = false;
2482 m.dirty = false;
2483 saved += 1;
2484 }
2485 Err(err) => errors.push(err.to_string()),
2486 }
2487 }
2488 true
2489 }
2490 });
2491 self.dir_mtimes = dir_mtimes(&self.dir);
2492 self.undo.clear();
2495 self.resort(keep);
2496 match errors.is_empty() {
2497 true => self.notify(Notice::Synced {
2498 deleted: removed,
2499 updated: saved,
2500 }),
2501 false => self.note(format!("sync errors: {}", errors.join("; "))),
2502 }
2503 }
2504
2505 pub fn create_folder(&mut self, spec: &str) -> Result<String, String> {
2516 let id = self.manage(
2517 spec,
2518 |_account, folder| Manage::Create(folder),
2519 |path| {
2520 maildir::create(&path).map_err(|e| format!("{e:#}"))?;
2521 Ok(path.display().to_string())
2522 },
2523 )?;
2524 Ok(format!("created {id}"))
2525 }
2526
2527 pub fn delete_folder(&mut self, spec: &str) -> Result<String, String> {
2528 let id = self.manage(
2529 spec,
2530 |_account, folder| Manage::Delete(folder),
2531 |path| {
2532 std::fs::remove_dir_all(&path).map_err(|e| format!("{e}"))?;
2535 Ok(path.display().to_string())
2536 },
2537 )?;
2538 Ok(format!("deleted {id}"))
2539 }
2540
2541 pub fn set_subscribed(&mut self, spec: &str, on: bool) -> Result<String, String> {
2542 let verb = if on {
2543 "subscribed to"
2544 } else {
2545 "unsubscribed from"
2546 };
2547 self.manage(
2548 spec,
2549 move |_account, folder| Manage::Subscribe(folder, on),
2550 |_path| Err("subscription is an IMAP notion".into()),
2551 )
2552 .map(|shown| format!("{verb} {shown}"))
2553 }
2554
2555 pub fn rename_folder(&mut self, spec: &str, new: &str) -> Result<String, String> {
2558 if new.trim().is_empty() {
2559 return Err("no new name given".into());
2560 }
2561 match remote::parse_spec(spec) {
2562 Some((account, from)) => {
2563 let to = new.trim().to_string();
2564 self.on_account(account, Manage::Rename(from.to_string(), to.clone()))?;
2565 Ok(format!("renamed to {to}"))
2566 }
2567 None => {
2568 let from = expand_tilde(spec);
2569 let to = expand_tilde(new);
2570 std::fs::rename(&from, &to).map_err(|e| format!("{e}"))?;
2571 Ok(format!("renamed to {}", to.display()))
2572 }
2573 }
2574 }
2575
2576 fn manage(
2580 &mut self,
2581 spec: &str,
2582 make: impl FnOnce(&str, String) -> Manage,
2583 local: impl FnOnce(std::path::PathBuf) -> Result<String, String>,
2584 ) -> Result<String, String> {
2585 match remote::parse_spec(spec) {
2586 Some((account, folder)) => {
2587 let action = make(account, folder.to_string());
2588 self.on_account(account, action)?;
2589 Ok(format!("imap:{account}/{folder}"))
2590 }
2591 None => local(expand_tilde(spec)),
2592 }
2593 }
2594
2595 fn on_account(&mut self, account: &str, action: Manage) -> Result<(), String> {
2598 self.settle();
2602 match &mut self.imap {
2603 Some(imap) if imap.facts.account.name == account => imap
2604 .blocking(Job::Manage(action))
2605 .map(drop)
2606 .map_err(|e| format!("{e:#}")),
2607 Some(_) | None => Err("that mailbox is not on the open account".into()),
2608 }
2609 }
2610
2611 pub fn folder_candidates(&mut self) -> Result<Vec<(String, usize)>> {
2612 self.settle();
2616 let mut dirs: Vec<(String, usize)> = self
2619 .config
2620 .mail
2621 .mailboxes
2622 .iter()
2623 .filter(|m| m.starts_with("imap:") || expand_tilde(m).join("cur").is_dir())
2624 .map(|m| {
2625 let count = if m.starts_with("imap:") {
2626 0
2627 } else {
2628 maildir::new_count(&expand_tilde(m))
2629 };
2630 (m.clone(), count)
2631 })
2632 .collect();
2633 match &mut self.imap {
2634 Some(imap) => {
2635 let account = imap.facts.account.name.clone();
2636 if let Done::Folders(folders) = imap.blocking(Job::Folders)? {
2637 dirs.extend(
2638 folders
2639 .into_iter()
2640 .map(|(f, unseen)| (format!("imap:{account}/{f}"), unseen)),
2641 );
2642 }
2643 }
2644 None => dirs.extend(
2645 maildir::discover(&self.dir)
2646 .iter()
2647 .map(|p| (p.display().to_string(), maildir::new_count(p))),
2648 ),
2649 }
2650 let mut seen: HashMap<String, usize> = HashMap::new();
2653 let mut unique: Vec<(String, usize)> = Vec::new();
2654 for (name, count) in dirs {
2655 match seen.get(&name) {
2656 Some(&at) => unique[at].1 = unique[at].1.max(count),
2657 None => {
2658 seen.insert(name.clone(), unique.len());
2659 unique.push((name, count));
2660 }
2661 }
2662 }
2663 let mut dirs = unique;
2664 sort_browser(&mut dirs, self.config.ui.sort_browser.as_deref());
2665 dirs.dedup_by(|a, b| {
2668 if a.0 == b.0 {
2669 b.1 = b.1.max(a.1);
2670 true
2671 } else {
2672 false
2673 }
2674 });
2675 Ok(dirs)
2676 }
2677
2678 pub fn unseen_count(&self, spec: &str) -> usize {
2687 match remote::parse_spec(spec) {
2688 Some(_) => self.unseen.get(spec).copied().unwrap_or(0),
2689 None => maildir::new_count(&expand_tilde(spec)),
2690 }
2691 }
2692
2693 fn refresh_unseen(&mut self) -> bool {
2696 let Some(imap) = &self.imap else {
2697 return false;
2698 };
2699 let account = imap.facts.account.name.clone();
2700 let wanted: Vec<(String, String)> = self
2701 .config
2702 .mail
2703 .mailboxes
2704 .iter()
2705 .filter_map(|spec| match remote::parse_spec(spec) {
2706 Some((a, folder)) if a == account => Some((spec.clone(), folder.to_string())),
2707 _ => None,
2708 })
2709 .collect();
2710 if wanted.is_empty() {
2711 return false;
2712 }
2713 let (specs, folders): (Vec<String>, Vec<String>) = wanted.into_iter().unzip();
2714 self.start(Job::Unseen(folders), Pending::Counts(specs))
2715 }
2716
2717 pub fn store_edited(&mut self, path: &Path, edited: &[u8]) {
2722 self.settle();
2726 match &mut self.imap {
2727 Some(imap) => {
2728 let Some(flags) = self
2729 .visible
2730 .get(self.sel)
2731 .map(|&i| self.msgs[i].env.file.flags)
2732 else {
2733 return;
2734 };
2735 let mailbox = imap.facts.mailbox.clone();
2736 if let Err(err) = imap.blocking(Job::Append {
2737 mailbox: Some(mailbox),
2738 flags,
2739 body: edited.to_vec(),
2740 }) {
2741 self.error(format!("cannot store the edited copy: {err:#}"));
2742 return;
2743 }
2744 let rules = self.delete_rules();
2745 if let Some(m) = self.cur_mut()
2746 && rules.mark(m)
2747 {
2748 m.dirty = true;
2749 }
2750 self.check_new_mail();
2751 self.note("edited copy appended; original marked deleted ($ purges)");
2752 }
2753 None => {
2754 if let Err(err) = std::fs::write(path, edited) {
2755 self.error(format!("cannot write message: {err}"));
2756 return;
2757 }
2758 self.rescan();
2759 self.note("message edited");
2760 }
2761 }
2762 }
2763
2764 pub fn mark_all_read(&mut self) {
2771 if self.deny_readonly() {
2772 return;
2773 }
2774 let targets: Vec<usize> = (0..self.msgs.len())
2775 .filter(|&i| {
2776 let f = &self.msgs[i].env.file;
2777 !f.flags.seen || f.is_new
2778 })
2779 .collect();
2780 if targets.is_empty() {
2781 self.note("no unread messages");
2782 return;
2783 }
2784 self.push_undo("mark all read", &targets);
2785 for &i in &targets {
2786 let m = &mut self.msgs[i];
2787 m.env.file.flags.seen = true;
2788 m.env.file.is_new = false;
2789 m.dirty = true;
2790 }
2791 self.note(format!("{} marked read", targets.len()));
2792 }
2793
2794 pub fn tag_span(&mut self, a: usize, b: usize) {
2798 if self.visible.is_empty() {
2799 return;
2800 }
2801 let last = self.visible.len() - 1;
2802 let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
2803 let (lo, hi) = (lo.min(last), hi.min(last));
2804 let targets: Vec<usize> = self.visible[lo..=hi].to_vec();
2805 self.push_undo("tag", &targets);
2806 for &i in &targets {
2807 self.msgs[i].env.tagged = true;
2808 }
2809 self.note(format!("{} tagged", targets.len()));
2810 }
2811
2812 pub fn mark_old_unread(&mut self) {
2813 if self.read_only || !self.config.mail.mark_old.unwrap_or(true) {
2814 return;
2815 }
2816 for m in &mut self.msgs {
2817 if m.env.file.is_new
2818 && !m.env.file.flags.seen
2819 && !m.env.file.flags.deleted
2820 && let Ok(path) = maildir::store_flags(&m.env.file)
2821 {
2822 m.env.file.path = path;
2823 m.env.file.is_new = false;
2824 }
2825 }
2826 }
2827
2828 pub fn ready_to_leave(&mut self) -> bool {
2832 if self.deleted_count() > 0 {
2833 self.error("deleted messages pending; sync with $ or undelete first");
2834 return false;
2835 }
2836 if self.pending_count() > 0 {
2837 self.sync(false);
2838 if self.pending_count() > 0 {
2839 return false; }
2841 }
2842 true
2843 }
2844
2845 pub fn smtp_account(&self) -> Option<Account> {
2849 if std::env::var("RMUT_SENDMAIL").is_ok() || self.config.mail.sendmail.is_some() {
2850 return None;
2851 }
2852 if let Some(imap) = &self.imap
2853 && imap.facts.account.smtp_host.is_some()
2854 {
2855 return Some(imap.facts.account.clone());
2856 }
2857 self.config
2858 .accounts
2859 .iter()
2860 .find(|a| a.smtp_host.is_some())
2861 .cloned()
2862 }
2863
2864 pub fn copy_message(&mut self, input: &str, delete: bool, tagged: bool, decode: bool) {
2868 if input.is_empty() {
2869 self.error("no mailbox given");
2870 return;
2871 }
2872 let targets = self.op_targets(tagged);
2873 if targets.is_empty() {
2874 return;
2875 }
2876 let paths = self.target_paths(tagged);
2878 if !self.have_bodies(
2879 &paths,
2880 Again::Copy {
2881 input: input.to_string(),
2882 delete,
2883 tagged,
2884 decode,
2885 },
2886 ) {
2887 return;
2888 }
2889 let mut created: Vec<PathBuf> = Vec::new();
2892 let mut marks = Vec::new();
2893 let mut copied = Vec::new();
2894 let mut errors: Vec<String> = Vec::new();
2895 let mut note = None;
2896 let mut target = String::new();
2897 for &i in &targets {
2898 match self.copy_one(i, input, decode, &mut created) {
2899 Ok(shown) => {
2900 if shown.starts_with("imap:") {
2901 note = Some(format!("the copy in {shown} stays"));
2902 }
2903 target = shown;
2904 marks.push(self.mark(i));
2905 copied.push(i);
2906 }
2907 Err(err) => errors.push(err),
2910 }
2911 }
2912 let verb = if delete { "save" } else { "copy" };
2913 if copied.is_empty() {
2914 self.error(format!("cannot {verb}: {}", errors.join("; ")));
2915 return;
2916 }
2917 self.push_undo_step(UndoStep {
2918 what: format!("{verb} to {target}"),
2919 marks,
2920 sel: self.selected_path(),
2921 created,
2922 note,
2923 rewritten: Vec::new(),
2924 });
2925 let n = copied.len();
2926 if delete {
2927 let rules = self.delete_rules();
2928 for &i in &copied {
2929 if rules.mark(&mut self.msgs[i]) {
2930 self.msgs[i].dirty = true;
2931 }
2932 }
2933 }
2934 if delete
2938 && !tagged
2939 && errors.is_empty()
2940 && let Some(pos) = self.step_message(true, true)
2941 {
2942 self.sel = pos;
2943 }
2944 let mut status = match (delete, n) {
2945 (true, 1) => format!("saved to {target} (original marked deleted)"),
2946 (true, _) => format!("saved {n} to {target} (originals marked deleted)"),
2947 (false, 1) => format!("copied to {target}"),
2948 (false, _) => format!("copied {n} to {target}"),
2949 };
2950 if !errors.is_empty() {
2951 status += &format!("; {} failed: {}", errors.len(), errors.join("; "));
2952 self.error(status);
2953 } else {
2954 self.note(status);
2955 }
2956 }
2957
2958 fn copy_one(
2962 &mut self,
2963 i: usize,
2964 spec: &str,
2965 decode: bool,
2966 created: &mut Vec<PathBuf>,
2967 ) -> Result<String, String> {
2968 self.settle();
2972 let flags = self.msgs[i].env.file.flags;
2973 let bytes = if decode {
2977 self.displayed_text(i)?.into_bytes()
2978 } else {
2979 self.message_bytes(i).ok_or("cannot read the message")?
2980 };
2981 match remote::parse_spec(spec) {
2982 Some((account, folder)) => match &mut self.imap {
2983 Some(imap) if imap.facts.account.name == account => imap
2984 .blocking(Job::Append {
2985 mailbox: Some(folder.to_string()),
2986 flags,
2987 body: bytes,
2988 })
2989 .map(|done| match done {
2990 Done::Folder(folder) => format!("imap:{account}/{folder}"),
2991 _ => format!("imap:{account}/{folder}"),
2992 })
2993 .map_err(|err| format!("{err:#}")),
2994 _ => Err("can only save to a folder of the open account".into()),
2995 },
2996 None => {
2997 let dir = expand_tilde(spec);
2998 maildir::create(&dir)
2999 .and_then(|()| maildir::deliver(&dir, &bytes, flags))
3000 .map(|path| {
3001 created.push(path);
3002 dir.display().to_string()
3003 })
3004 .map_err(|err| format!("{err:#}"))
3005 }
3006 }
3007 }
3008
3009 pub fn create_alias(&mut self, nick: &str, addr: &str) {
3011 if nick.is_empty() || nick.contains(char::is_whitespace) {
3012 self.error("the alias nick must be one word");
3013 return;
3014 }
3015 match alias::append_to(self.config.mail.alias_file.as_deref(), nick, addr) {
3016 Ok(_) => self.note(format!("added: alias {nick} {addr}")),
3017 Err(err) => self.error(format!("cannot save the alias: {err:#}")),
3018 }
3019 }
3020
3021 pub fn pipe_message(&mut self, command: &str, tagged: bool) {
3025 if command.is_empty() {
3026 self.error("no command given");
3027 return;
3028 }
3029 let paths = self.target_paths(tagged);
3030 if !self.have_bodies(
3031 &paths,
3032 Again::Pipe {
3033 command: command.to_string(),
3034 tagged,
3035 },
3036 ) {
3037 return;
3038 }
3039 let targets = self.op_targets(tagged);
3040 let n = targets.len();
3041 let decode = self.config.mail.pipe_decode.unwrap_or(false);
3042 let split = self.config.mail.pipe_split.unwrap_or(false);
3043 let sep = self
3044 .config
3045 .mail
3046 .pipe_sep
3047 .clone()
3048 .unwrap_or_else(|| "\n".into());
3049 match self.run_over(&targets, command, decode, split, &sep) {
3050 Ok(()) => self.note(match n {
3051 1 => format!("piped to {command}"),
3052 _ => format!("piped {n} messages to {command}"),
3053 }),
3054 Err(err) => self.error(format!("pipe failed: {err}")),
3055 }
3056 }
3057
3058 pub fn bounce_current(&mut self, to: &str, tagged: bool) {
3061 let rcpts = compose::addresses(to);
3062 if rcpts.is_empty() {
3063 self.error(format!("cannot parse the addresses in {to:?}"));
3064 return;
3065 }
3066 let paths = self.target_paths(tagged);
3067 if !self.have_bodies(
3068 &paths,
3069 Again::Bounce {
3070 to: to.to_string(),
3071 tagged,
3072 },
3073 ) {
3074 return;
3075 }
3076 let targets = self.op_targets(tagged);
3077 let mut sent = 0usize;
3078 for i in targets {
3079 let Some(bytes) = self.message_bytes(i) else {
3080 return;
3081 };
3082 if let Err(err) = self.bounce_one(&bytes, to, &rcpts) {
3083 self.error(format!("bounce failed: {err:#}"));
3084 return;
3085 }
3086 sent += 1;
3087 }
3088 self.note(match sent {
3089 1 => format!("message bounced to {to}"),
3090 _ => format!("{sent} messages bounced to {to}"),
3091 });
3092 }
3093
3094 fn bounce_one(&mut self, bytes: &[u8], to: &str, rcpts: &[String]) -> Result<()> {
3097 let host = maildir::hostname();
3098 let from = self
3099 .current_identity(rcpts)
3100 .from_line()
3101 .unwrap_or_else(|| default_from(&host));
3102 let text = compose::bounce_text(
3103 bytes,
3104 &from,
3105 to,
3106 &compose::rfc2822_now(),
3107 &compose::make_message_id(&host),
3108 );
3109 let envelope_from = compose::bare_address(&from).unwrap_or_else(|| from.clone());
3110 match self.smtp_account() {
3111 Some(account) => account_password(&account).and_then(|password| {
3112 smtp::send(&account, &password, &envelope_from, rcpts, text.as_bytes())
3113 }),
3114 None => run_sendmail(
3115 text.as_bytes(),
3116 self.config.mail.sendmail.as_deref(),
3117 Some(rcpts),
3118 ),
3119 }
3120 }
3121
3122 pub fn open_message(&mut self) {
3125 let Some(path) = self.selected_path() else {
3126 return;
3127 };
3128 if !self.have_bodies(std::slice::from_ref(&path), Again::View) {
3129 return;
3130 }
3131 self.mark_read();
3132 match self.load_view(&path) {
3133 Ok(view) => self.requests.push(Request::ShowMessage(Box::new(view))),
3134 Err(err) => self.error(format!("cannot open message: {err:#}")),
3135 }
3136 }
3137
3138 pub fn load_view(&mut self, path: &Path) -> Result<message::MessageView> {
3141 self.settle();
3145 if remote::is_partial(path)
3147 && let Some(imap) = &mut self.imap
3148 {
3149 imap.blocking(Job::FetchBodies(vec![path.to_path_buf()]))
3150 .context("cannot fetch message")?;
3151 if let Some(m) = self.msgs.iter_mut().find(|m| m.env.file.path == path)
3153 && let Ok(raw) = std::fs::read(path)
3154 {
3155 m.env.lines = Some(message::body_lines(&raw));
3156 }
3157 }
3158 let mut view = message::load_with(path, &self.display)?;
3159 if let Ok(raw) = std::fs::read(path)
3162 && let Some(p) = pgp::view(&self.config.pgp, &raw)
3163 {
3164 match p.body {
3165 Some(pgp::Body::Entity(raw)) => {
3169 view.body = message::render_entity(&raw, &self.display);
3170 }
3171 Some(pgp::Body::Text(text)) => view.body = text,
3172 None => {}
3173 }
3174 view.body = format!("{}\n\n{}", p.note, view.body);
3175 }
3176 Ok(view)
3177 }
3178
3179 fn displayed_text(&mut self, i: usize) -> Result<String, String> {
3183 let path = self.msgs[i].env.file.path.clone();
3184 let view = self
3185 .load_view(&path)
3186 .map_err(|err| format!("cannot decode: {err:#}"))?;
3187 let mut text = String::new();
3188 for (name, value) in &view.brief {
3189 text += &format!("{name}: {value}\n");
3190 }
3191 text.push('\n');
3192 text += &view.body;
3193 if !text.ends_with('\n') {
3194 text.push('\n');
3195 }
3196 Ok(text)
3197 }
3198
3199 pub fn print_current(&mut self, tagged: bool) {
3203 let paths = self.target_paths(tagged);
3204 if !self.have_bodies(&paths, Again::Print { tagged }) {
3205 return;
3206 }
3207 let targets = self.op_targets(tagged);
3208 if targets.is_empty() {
3209 return;
3210 }
3211 let command = self
3212 .config
3213 .mail
3214 .print
3215 .clone()
3216 .unwrap_or_else(|| "lpr".into());
3217 let decode = self.config.mail.print_decode.unwrap_or(true);
3218 let split = self.config.mail.print_split.unwrap_or(false);
3219 let n = targets.len();
3220 match self.run_over(&targets, &command, decode, split, "\n") {
3221 Ok(()) => self.note(match n {
3222 1 => format!("printed via {command}"),
3223 _ => format!("printed {n} messages via {command}"),
3224 }),
3225 Err(err) => self.error(format!("print failed: {err}")),
3226 }
3227 }
3228
3229 fn op_one_bytes(&mut self, i: usize, decode: bool) -> Result<Vec<u8>, String> {
3232 if decode {
3233 self.displayed_text(i).map(String::into_bytes)
3234 } else {
3235 self.message_bytes(i)
3236 .map(|mut b| {
3237 if !b.ends_with(b"\n") {
3238 b.push(b'\n');
3239 }
3240 b
3241 })
3242 .ok_or_else(|| "cannot read the message".into())
3243 }
3244 }
3245
3246 fn run_over(
3250 &mut self,
3251 targets: &[usize],
3252 command: &str,
3253 decode: bool,
3254 split: bool,
3255 sep: &str,
3256 ) -> Result<(), String> {
3257 if split {
3258 for &i in targets {
3259 let bytes = self.op_one_bytes(i, decode)?;
3260 pipe_to(command, &bytes).map_err(|err| format!("{err:#}"))?;
3261 }
3262 return Ok(());
3263 }
3264 let mut all: Vec<u8> = Vec::new();
3265 for (n, &i) in targets.iter().enumerate() {
3266 if n > 0 {
3267 all.extend_from_slice(sep.as_bytes());
3268 }
3269 all.extend_from_slice(&self.op_one_bytes(i, decode)?);
3270 }
3271 pipe_to(command, &all).map_err(|err| format!("{err:#}"))
3272 }
3273
3274 pub fn current_identity(&self, rcpts: &[String]) -> rmut_core::config::Identity {
3277 self.config.identity_for(
3278 &self.title,
3279 rcpts,
3280 self.imap.as_ref().map(|imap| &imap.facts.account),
3281 )
3282 }
3283
3284 pub fn matching_message_hooks(&self) -> Vec<usize> {
3286 let Some(env) = self.visible.get(self.sel).map(|&mi| &self.msgs[mi].env) else {
3287 return Vec::new();
3288 };
3289 let pos = pattern::Position {
3292 number: self.sel + 1,
3293 current: self.sel + 1,
3294 last: self.visible.len(),
3295 duplicate: false,
3296 };
3297 self.message_hooks
3298 .iter()
3299 .enumerate()
3300 .filter(|(_, h)| pattern::matches_in(&h.patterns, env, self.scope(pos), None))
3301 .map(|(i, _)| i)
3302 .collect()
3303 }
3304
3305 pub fn reply_hook_lines(&self, path: &Path) -> Vec<String> {
3309 if self.reply_hooks.is_empty() {
3310 return Vec::new();
3311 }
3312 let Some(env) = self.msgs.iter().find(|m| m.env.file.path == *path) else {
3313 return Vec::new();
3314 };
3315 let scope = self.scope(pattern::Position::default());
3316 self.reply_hooks
3317 .iter()
3318 .filter(|h| pattern::matches_in(&h.patterns, &env.env, scope, None))
3319 .map(|h| h.value.clone())
3320 .collect()
3321 }
3322
3323 pub fn fcc_hook_target(&self, draft: &str, path: &Path) -> Option<String> {
3326 if self.fcc_hooks.is_empty() {
3327 return None;
3328 }
3329 let env = compose::draft_envelope(draft, path);
3330 let scope = self.scope(pattern::Position::default());
3331 self.fcc_hooks
3332 .iter()
3333 .find(|h| pattern::matches_in(&h.patterns, &env, scope, None))
3334 .map(|h| h.value.clone())
3335 }
3336
3337 pub fn crypt_key_for(&self, address: &str) -> Option<String> {
3340 self.crypt_hooks
3341 .iter()
3342 .find(|(m, _)| m.is_match(address))
3343 .map(|(_, key)| key.clone())
3344 }
3345
3346 pub fn notmuch_mirror(&mut self, query: &str) -> Option<(PathBuf, usize)> {
3354 if query.is_empty() {
3355 return None;
3356 }
3357 let out = Command::new("notmuch")
3358 .args(["search", "--output=files", "--limit=1000", "--", query])
3359 .output();
3360 let out = match out {
3361 Ok(out) if out.status.success() => out,
3362 Ok(out) => {
3363 let err = String::from_utf8_lossy(&out.stderr);
3364 self.error(format!("notmuch: {}", err.trim()));
3365 return None;
3366 }
3367 Err(err) => {
3368 self.error(format!("notmuch: {err}"));
3369 return None;
3370 }
3371 };
3372 let stdout = String::from_utf8_lossy(&out.stdout);
3373 let files: Vec<&str> = stdout.lines().filter(|l| !l.trim().is_empty()).collect();
3374 if files.is_empty() {
3375 self.error("notmuch: no matches");
3376 return None;
3377 }
3378 if !self.ready_to_leave() {
3379 return None;
3380 }
3381 let dir = remote::cache_base().join("notmuch");
3382 let build = || -> Result<()> {
3383 for sub in ["cur", "new", "tmp"] {
3384 std::fs::create_dir_all(dir.join(sub))?;
3385 }
3386 for entry in std::fs::read_dir(dir.join("cur"))?.flatten() {
3387 let _ = std::fs::remove_file(entry.path());
3388 }
3389 for (i, file) in files.iter().enumerate() {
3390 let base = Path::new(file)
3391 .file_name()
3392 .map(|n| n.to_string_lossy().into_owned())
3393 .unwrap_or_else(|| format!("{i}"));
3394 let _ = std::os::unix::fs::symlink(
3397 file,
3398 dir.join("cur").join(format!("{i:04}.{base}")),
3399 );
3400 }
3401 Ok(())
3402 };
3403 if let Err(err) = build() {
3404 self.note(format!("notmuch mirror: {err:#}"));
3405 return None;
3406 }
3407 Some((dir, files.len()))
3408 }
3409
3410 pub fn compose_base(&self) -> Option<ComposeBase> {
3411 let &mi = self.visible.get(self.sel)?;
3412 let env = &self.msgs[mi].env;
3413 let view = message::load(&env.file.path).ok()?;
3414 let get = |name: &str| {
3415 view.all
3416 .iter()
3417 .filter(|(k, _)| k.eq_ignore_ascii_case(name))
3418 .map(|(_, v)| v.clone())
3419 .collect::<Vec<_>>()
3420 .join(", ")
3421 };
3422 let from_hdr = get("From");
3423 let (reply_to, has_reply_to) = {
3424 let rt = get("Reply-To");
3425 if rt.trim().is_empty() {
3426 (from_hdr.clone(), false)
3427 } else {
3428 let differs = rt.trim() != from_hdr.trim();
3429 (rt, differs)
3430 }
3431 };
3432 Some(ComposeBase {
3433 path: env.file.path.clone(),
3434 reply_to,
3435 from_hdr,
3436 has_reply_to,
3437 orig_to: get("To"),
3438 orig_cc: get("Cc"),
3439 list_post: compose::list_post_address(&get("List-Post")),
3440 followup_to: get("Mail-Followup-To"),
3441 from_addr: compose::addresses(&get("From"))
3442 .into_iter()
3443 .next()
3444 .unwrap_or_default(),
3445 from_display: env.from.clone(),
3446 subject: env.subject.clone(),
3447 date: env.date,
3448 msg_id: env.msg_id.clone(),
3449 references: env.references.clone(),
3450 })
3451 }
3452
3453 pub fn list_target(&self, base: &ComposeBase) -> Option<String> {
3459 if let Some(addr) = &base.list_post {
3460 return Some(addr.clone());
3461 }
3462 let mut candidates = compose::addresses(&base.orig_to);
3463 candidates.extend(compose::addresses(&base.orig_cc));
3464 candidates
3465 .into_iter()
3466 .find(|a| self.lists.iter().any(|m| m.is_match(a)))
3467 }
3468
3469 pub fn followup_header(&self, to: &str, cc: Option<&str>, from: &str) -> Option<String> {
3473 if self.lists.is_empty() {
3474 return None;
3475 }
3476 let mut rcpts = compose::addresses(to);
3477 rcpts.extend(compose::addresses(cc.unwrap_or_default()));
3478 if !rcpts
3479 .iter()
3480 .any(|a| self.lists.iter().any(|m| m.is_match(a)))
3481 {
3482 return None;
3483 }
3484 let subscribed = rcpts
3485 .iter()
3486 .any(|a| self.subscribed.iter().any(|m| m.is_match(a)));
3487 let value = compose::followup_to(to, cc.unwrap_or_default(), self.me(), subscribed, from);
3488 (!value.is_empty()).then_some(value)
3489 }
3490
3491 pub fn edit_headers(&self) -> bool {
3494 self.config.mail.edit_headers.unwrap_or(false)
3495 }
3496
3497 pub fn stage_draft(&self, text: &str) -> Result<(PathBuf, Option<String>)> {
3501 let text = compose::apply_my_hdr(text, &self.config.mail.my_hdr);
3505 if self.edit_headers() {
3506 return Ok((write_draft(&text)?, None));
3507 }
3508 let (head, body) = text.split_once("\n\n").unwrap_or((text.trim_end(), ""));
3509 Ok((write_draft(body)?, Some(head.to_string())))
3510 }
3511
3512 pub fn compose_from(&self, base: Option<&ComposeBase>, to: &str) -> Option<String> {
3516 let realname = self.config.identity.reverse_realname.unwrap_or(true);
3520 if self.config.identity.reverse_name
3521 && let Some(b) = base
3522 && let Some(from) = compose::reverse_from(&b.orig_to, &b.orig_cc, self.me(), realname)
3523 {
3524 let rcpts = compose::addresses(to);
3525 return Some(match self.current_identity(&rcpts).name {
3526 Some(name) if !realname && !from.contains('<') => format!("{name} <{from}>"),
3527 _ => from,
3528 });
3529 }
3530 let rcpts = compose::addresses(to);
3531 self.current_identity(&rcpts).from_line()
3532 }
3533
3534 pub fn forward_attaches(&self) -> bool {
3535 self.config.mail.forward.as_deref() == Some("attach")
3536 }
3537
3538 pub fn default_fcc(&self, draft: Option<&Compose>) -> String {
3546 if let Some(compose) = draft
3547 && let Ok(full) = draft_full(compose)
3548 && let Some(mailbox) = self.fcc_hook_target(&full, &compose.path)
3549 {
3550 return mailbox;
3551 }
3552 match &self.imap {
3553 Some(imap) => format!(
3554 "imap:{}/{}",
3555 imap.facts.account.name, imap.facts.account.sent_folder
3556 ),
3557 None => self.config.mail.sent.clone().unwrap_or_default(),
3558 }
3559 }
3560
3561 pub fn default_security(&self) -> Security {
3563 Self::combine(
3564 self.config.pgp.sign_by_default,
3565 self.config.pgp.encrypt_by_default,
3566 )
3567 }
3568
3569 fn combine(sign: bool, encrypt: bool) -> Security {
3570 match (sign, encrypt) {
3571 (true, true) => Security::Both,
3572 (true, false) => Security::Sign,
3573 (false, true) => Security::Encrypt,
3574 (false, false) => Security::None,
3575 }
3576 }
3577
3578 pub fn security_for(&self, kind: &ComposeKind, base: Option<&ComposeBase>) -> Security {
3585 let mut sign = self.config.pgp.sign_by_default;
3586 let mut encrypt = self.config.pgp.encrypt_by_default;
3587 let is_reply = matches!(
3588 kind,
3589 ComposeKind::Reply | ComposeKind::GroupReply | ComposeKind::ListReply
3590 );
3591 if is_reply
3592 && let Some(base) = base
3593 && let Ok(raw) = std::fs::read(&base.path)
3594 {
3595 let crypto = rmut_core::pgp::classify(&raw);
3596 if crypto.encrypted && self.config.pgp.reply_encrypt {
3597 encrypt = true;
3598 }
3599 if crypto.signed && self.config.pgp.reply_sign {
3600 sign = true;
3601 }
3602 if crypto.encrypted && self.config.pgp.reply_sign_encrypted {
3608 sign = true;
3609 }
3610 }
3611 Self::combine(sign, encrypt)
3612 }
3613
3614 pub fn tick_outbox(&mut self) {
3617 while self.outbox.first().is_some_and(|h| h.due <= Instant::now()) {
3618 let held = self.outbox.remove(0);
3619 self.deliver(held);
3620 }
3621 if let Some(next) = self.outbox.first()
3622 && !self.notice().is_some_and(|n| n.is_error())
3623 {
3624 let left = next.due.saturating_duration_since(Instant::now()).as_secs() + 1;
3625 self.note(format!("sending {} in {left}s (z cancels)", next.label));
3626 }
3627 }
3628
3629 pub fn flush_outbox(&mut self) -> Vec<String> {
3636 let mut trouble = Vec::new();
3637 while !self.outbox.is_empty() {
3638 let held = self.outbox.remove(0);
3639 let label = held.label.clone();
3640 self.deliver(held);
3641 if let Some(err) = self.notice().filter(|n| n.is_error()).map(|n| n.text()) {
3642 trouble.push(format!("{label}: {err}"));
3643 self.clear_notice();
3644 }
3645 }
3646 trouble
3647 }
3648
3649 pub fn hold_send(&mut self, held: Held) {
3653 self.outbox.push(held);
3654 }
3655
3656 pub fn cancel_send(&mut self) -> bool {
3659 let Some(held) = self.outbox.pop() else {
3660 return false;
3661 };
3662 self.note(format!("send cancelled: {}", held.label));
3663 self.hand_back(held.state);
3664 true
3665 }
3666
3667 pub fn deliver(&mut self, held: Held) {
3672 self.settle();
3676 let Held {
3677 state: compose_state,
3678 text: final_text,
3679 fcc: chosen,
3680 ..
3681 } = held;
3682 let send_result = match self.smtp_account() {
3683 Some(account) => send_via_smtp(&account, &final_text),
3684 None => run_sendmail(
3685 final_text.as_bytes(),
3686 self.config.mail.sendmail.as_deref(),
3687 None,
3688 ),
3689 };
3690 match send_result {
3691 Ok(()) => {
3692 let mut note = String::from("message sent");
3693 let skip_copy = chosen.as_deref() == Some("")
3694 || (chosen.is_none() && self.config.mail.copy == Some(false));
3695 if skip_copy {
3696 } else if let Some(fcc) = chosen
3698 .as_deref()
3699 .filter(|f| Some(*f) != Some(self.default_fcc(None).as_str()))
3700 {
3701 let dir = expand_tilde(fcc);
3703 let flags = maildir::Flags {
3704 seen: true,
3705 ..Default::default()
3706 };
3707 if dir.join("cur").is_dir()
3708 && maildir::deliver(&dir, final_text.as_bytes(), flags).is_ok()
3709 {
3710 note += &format!(", copy in {fcc}");
3711 } else {
3712 note += &format!(", Fcc to {fcc} failed");
3713 }
3714 } else {
3715 match &mut self.imap {
3716 Some(imap) => match imap.blocking(Job::Append {
3719 mailbox: None,
3720 flags: maildir::Flags {
3721 seen: true,
3722 ..Default::default()
3723 },
3724 body: final_text.clone().into_bytes(),
3725 }) {
3726 Ok(Done::Folder(folder)) => note += &format!(", copy in {folder}"),
3727 Ok(_) => note += ", copy in Sent",
3728 Err(_) => note += ", Fcc to Sent failed",
3729 },
3730 None => {
3731 let sent_dir = self
3732 .config
3733 .mail
3734 .sent
3735 .as_deref()
3736 .map(expand_tilde)
3737 .filter(|p| p.join("cur").is_dir())
3738 .or_else(|| {
3739 maildir::find_special(&self.dir, &["sent", "sent-mail"])
3740 });
3741 match sent_dir {
3742 Some(sent) => {
3743 let flags = maildir::Flags {
3744 seen: true,
3745 ..Default::default()
3746 };
3747 match maildir::deliver(&sent, final_text.as_bytes(), flags) {
3748 Ok(_) => note += ", copy in Sent",
3749 Err(_) => note += ", Fcc to Sent failed",
3750 }
3751 }
3752 None => note += " (no Sent maildir, no copy kept)",
3753 }
3754 }
3755 }
3756 }
3757 if let Ok(full) = draft_full(&compose_state) {
3759 for a in compose::extract_attachments(&full).1 {
3760 if a.unlink {
3761 let _ = std::fs::remove_file(&a.path);
3762 }
3763 }
3764 }
3765 let _ = std::fs::remove_file(&compose_state.path);
3766 if let Some(src) = &compose_state.recall_source {
3767 let _ = std::fs::remove_file(src);
3768 }
3769 self.note(note);
3770 }
3771 Err(err) => {
3772 self.error(format!("send failed: {err:#}"));
3773 self.hand_back(compose_state);
3774 }
3775 }
3776 }
3777
3778 pub fn attachment_forgotten(&self, raw: &str, compose: &Compose) -> bool {
3783 if self.config.mail.abort_noattach.as_deref().unwrap_or("no") == "no" {
3784 return false;
3785 }
3786 if compose.attach.is_some() || !compose::extract_attachments(raw).1.is_empty() {
3787 return false;
3788 }
3789 let body = raw.split_once("\n\n").map_or("", |(_, body)| body);
3790 for line in body.lines() {
3791 if line.trim_end() == "--" || line == "-- " {
3792 break;
3793 }
3794 if self.quote_re.is_match(line) {
3795 continue;
3796 }
3797 if self.attach_re.is_match(line) {
3798 return true;
3799 }
3800 }
3801 false
3802 }
3803
3804 pub fn secure_message(
3808 &self,
3809 security: Security,
3810 text: String,
3811 files: &[compose::Attachment],
3812 original: Option<&[u8]>,
3813 ) -> Result<String> {
3814 let cfg = &self.config.pgp;
3815 let recipients = |text: &str| -> Result<Vec<String>> {
3818 let (mut rcpts, _) = compose::smtp_envelope(text)?;
3819 if let Some(from) = compose::from_address(text) {
3820 rcpts.push(from);
3821 }
3822 for r in &mut rcpts {
3825 if let Some(key) = self.crypt_key_for(r) {
3826 *r = key;
3827 }
3828 }
3829 rcpts.sort();
3830 rcpts.dedup();
3831 Ok(rcpts)
3832 };
3833 let flowed = self.config.mail.text_flowed;
3834 if files.is_empty() && original.is_none() {
3835 return match security {
3836 Security::None if flowed => Ok(compose::flow_plain(&text)),
3839 Security::None => Ok(text),
3840 Security::Sign => pgp::sign_message(cfg, &text, flowed),
3841 Security::Encrypt | Security::Both => pgp::encrypt_message(
3842 cfg,
3843 &recipients(&text)?,
3844 security == Security::Both,
3845 &text,
3846 flowed,
3847 ),
3848 };
3849 }
3850 let (head, body) = text.split_once("\n\n").unwrap_or((text.trim_end(), ""));
3851 let entity = compose::mixed_entity(body, files, original, flowed)?;
3852 match security {
3853 Security::None => Ok(format!("{}\nMIME-Version: 1.0\n{entity}", head.trim_end())),
3854 Security::Sign => pgp::sign_entity(cfg, head, entity.as_bytes()),
3855 Security::Encrypt | Security::Both => pgp::encrypt_entity(
3856 cfg,
3857 &recipients(&text)?,
3858 security == Security::Both,
3859 head,
3860 entity.as_bytes(),
3861 ),
3862 }
3863 }
3864
3865 pub fn postponed_dir(&self) -> Option<PathBuf> {
3866 self.config
3867 .mail
3868 .postponed
3869 .as_deref()
3870 .map(expand_tilde)
3871 .filter(|p| p.join("cur").is_dir())
3872 .or_else(|| {
3873 maildir::find_special(&self.dir, &["drafts", "postponed", "rmut-postponed"])
3874 })
3875 }
3876
3877 pub fn has_postponed(&self) -> bool {
3878 self.postponed_dir()
3879 .and_then(|d| maildir::scan(&d).ok())
3880 .is_some_and(|files| !files.is_empty())
3881 }
3882
3883 pub fn postpone_draft(&mut self, compose_state: Compose) {
3886 let target = match self.postponed_dir() {
3887 Some(d) => Ok(d),
3888 None => {
3889 let d = self.dir.join(".rmut-postponed");
3890 maildir::create(&d).map(|()| d)
3891 }
3892 };
3893 let result = target.and_then(|dir| {
3894 let bytes = draft_full(&compose_state)?.into_bytes();
3897 let flags = maildir::Flags {
3898 draft: true,
3899 seen: true,
3900 ..Default::default()
3901 };
3902 maildir::deliver(&dir, &bytes, flags)
3903 });
3904 match result {
3905 Ok(path) => {
3906 let _ = std::fs::remove_file(&compose_state.path);
3907 self.note(format!("postponed to {}", path.display()));
3908 }
3909 Err(err) => {
3910 self.error(format!(
3911 "postpone failed: {err:#}; draft at {}",
3912 compose_state.path.display()
3913 ));
3914 }
3915 }
3916 }
3917
3918 pub fn recall_file(&mut self, source: PathBuf) -> Option<Compose> {
3921 let result = std::fs::read_to_string(&source)
3922 .map_err(anyhow::Error::from)
3923 .and_then(|content| self.stage_draft(&content));
3924 match result {
3925 Ok((path, hidden_head)) => Some(Compose {
3926 path,
3927 recall_source: Some(source),
3928 security: self.default_security(),
3929 attach: None,
3930 hidden_head,
3931 fcc: None,
3932 }),
3933 Err(err) => {
3934 self.error(format!("cannot recall: {err:#}"));
3935 None
3936 }
3937 }
3938 }
3939
3940 pub fn draft_head(&self) -> String {
3942 let Some(c) = &self.draft else {
3943 return String::new();
3944 };
3945 match &c.hidden_head {
3946 Some(head) => head.clone(),
3947 None => {
3948 let text = std::fs::read_to_string(&c.path).unwrap_or_default();
3949 match text.split_once("\n\n") {
3950 Some((head, _)) => head.to_string(),
3951 None => text.trim_end().to_string(),
3952 }
3953 }
3954 }
3955 }
3956
3957 pub fn edit_draft_head(&mut self, f: impl Fn(&str) -> String) {
3959 let Some(c) = &mut self.draft else {
3960 return;
3961 };
3962 match &mut c.hidden_head {
3963 Some(head) => *head = f(head),
3964 None => {
3965 if let Ok(text) = std::fs::read_to_string(&c.path) {
3966 let (head, body) = text.split_once("\n\n").unwrap_or((text.trim_end(), ""));
3967 let _ = std::fs::write(&c.path, format!("{}\n\n{body}", f(head)));
3968 }
3969 }
3970 }
3971 }
3972
3973 pub fn set_draft_header(&mut self, name: &str, value: &str) {
3975 let value = value.trim().to_string();
3976 let name = name.to_string();
3977 self.edit_draft_head(|head| {
3978 let mut lines: Vec<&str> = head
3979 .lines()
3980 .filter(|l| {
3981 !l.split_once(':')
3982 .is_some_and(|(k, _)| k.trim().eq_ignore_ascii_case(&name))
3983 })
3984 .collect();
3985 let added = format!("{name}: {value}");
3986 if !value.is_empty() {
3987 lines.push(&added);
3988 }
3989 lines.join("\n")
3990 });
3991 }
3992
3993 pub fn draft_header(&self, name: &str) -> String {
3994 header_value(&self.draft_head(), name).unwrap_or_default()
3995 }
3996
3997 pub fn send_draft(&mut self) -> Option<Ask> {
4001 let compose_state = self.draft.take()?;
4002 let raw = match draft_full(&compose_state) {
4003 Ok(r) => r,
4004 Err(err) => {
4005 self.error(format!("cannot read draft: {err}"));
4006 return None;
4007 }
4008 };
4009 if !mem::take(&mut self.attach_confirmed) && self.attachment_forgotten(&raw, &compose_state)
4012 {
4013 self.draft = Some(compose_state);
4014 match self.config.mail.abort_noattach.as_deref() {
4015 Some("yes") => {
4018 self.error("no attachment: not sent (abort_noattach); a attaches one");
4019 self.requests.push(Request::ShowDraft);
4020 return None;
4021 }
4022 _ => {
4023 return Some(Ask::Key {
4024 label:
4025 "The body mentions an attachment and none is attached. Send? (y/n): "
4026 .into(),
4027 what: AskKind::NoAttach,
4028 });
4029 }
4030 }
4031 }
4032 let draft_path = self
4035 .draft
4036 .as_ref()
4037 .map(|c| c.path.clone())
4038 .unwrap_or_default();
4039 let hook_fcc = self.fcc_hook_target(&raw, &draft_path);
4040 let (raw, files) = compose::extract_attachments(&raw);
4041 let host = maildir::hostname();
4042 let from = self
4043 .current_identity(&[])
4044 .from_line()
4045 .unwrap_or_else(|| default_from(&host));
4046 let msg_host = self
4048 .config
4049 .mail
4050 .hostname
4051 .clone()
4052 .filter(|h| !h.trim().is_empty())
4053 .unwrap_or(host);
4054 let final_text = match compose::finalize_with(
4055 &raw,
4056 &from,
4057 &compose::make_message_id(&msg_host),
4058 &compose::rfc2822_now(),
4059 self.config.mail.user_agent.unwrap_or(false),
4060 ) {
4061 Ok(t) => t,
4062 Err(err) => {
4063 self.error(format!("{err}; press e to edit"));
4064 self.hand_back(compose_state);
4065 return None;
4066 }
4067 };
4068 let original = match &compose_state.attach {
4069 Some(path) => match std::fs::read(path) {
4070 Ok(bytes) => Some(bytes),
4071 Err(err) => {
4072 self.error(format!("cannot attach the original: {err}"));
4073 self.hand_back(compose_state);
4074 return None;
4075 }
4076 },
4077 None => None,
4078 };
4079 let final_text = match self.secure_message(
4080 compose_state.security,
4081 final_text,
4082 &files,
4083 original.as_deref(),
4084 ) {
4085 Ok(t) => t,
4086 Err(err) => {
4087 self.error(format!("{err:#}; e edits, s changes security"));
4088 self.hand_back(compose_state);
4089 return None;
4090 }
4091 };
4092 let held = Held {
4095 text: final_text,
4096 fcc: compose_state.fcc.clone().or(hook_fcc),
4097 label: {
4098 let head = raw.split_once("\n\n").map_or(raw.as_str(), |(h, _)| h);
4099 header_value(head, "Subject")
4100 .filter(|s| !s.trim().is_empty())
4101 .or_else(|| header_value(head, "To"))
4102 .unwrap_or_else(|| "message".into())
4103 },
4104 state: compose_state,
4105 due: Instant::now() + Duration::from_secs(self.config.mail.undo_send),
4106 };
4107 if self.config.mail.undo_send > 0 {
4109 self.note(format!(
4110 "sending {} in {}s (z cancels)",
4111 held.label, self.config.mail.undo_send
4112 ));
4113 self.hold_send(held);
4114 return None;
4115 }
4116 self.deliver(held);
4117 None
4118 }
4119
4120 fn confirm_attachment(&mut self) {
4123 self.attach_confirmed = true;
4124 }
4125
4126 fn hand_back(&mut self, draft: Compose) {
4128 self.draft = Some(draft);
4129 self.requests.push(Request::ShowDraft);
4130 }
4131
4132 pub fn draft(&self) -> Option<&Compose> {
4134 self.draft.as_ref()
4135 }
4136
4137 pub fn draft_mut(&mut self) -> Option<&mut Compose> {
4138 self.draft.as_mut()
4139 }
4140
4141 pub fn take_draft(&mut self) -> Option<Compose> {
4144 self.draft.take()
4145 }
4146
4147 pub fn set_draft(&mut self, draft: Compose) {
4154 if let Some((path, staged)) = self.staged.take()
4155 && self.config.mail.abort_unmodified.unwrap_or(true)
4156 && path == draft.path
4157 && std::fs::read_to_string(&draft.path).is_ok_and(|now| now == staged)
4158 {
4159 let _ = std::fs::remove_file(&draft.path);
4160 self.draft = None;
4161 self.error("aborted unmodified message");
4162 return;
4163 }
4164 self.draft = Some(draft);
4165 }
4166
4167 pub fn set_attach_field(&mut self, k: usize, input: &str, is_type: bool) {
4172 let value = input.trim().to_string();
4173 self.edit_draft_head(|head| {
4174 let mut seen = 0usize;
4175 head.lines()
4176 .map(|l| {
4177 let is_attach = l
4178 .split_once(':')
4179 .is_some_and(|(key, _)| key.trim().eq_ignore_ascii_case("attach"));
4180 if is_attach {
4181 let (_, mut atts) = compose::extract_attachments(l);
4184 if let Some(mut a) = atts.pop() {
4185 let idx = seen;
4186 seen += 1;
4187 if idx == k {
4188 let new = (!value.is_empty()).then(|| value.clone());
4189 if is_type {
4190 a.mime = new;
4191 } else {
4192 a.description = new;
4193 }
4194 return compose::attach_line(&a);
4195 }
4196 }
4197 }
4198 l.to_string()
4199 })
4200 .collect::<Vec<_>>()
4201 .join("\n")
4202 });
4203 }
4204
4205 pub fn attach_index(&mut self, sel: usize) -> Option<usize> {
4211 let fixed = 1 + usize::from(self.draft().is_some_and(|c| c.attach.is_some()));
4212 if sel < fixed {
4213 self.error("only Attach: files can be changed here");
4214 return None;
4215 }
4216 Some(sel - fixed)
4217 }
4218
4219 pub fn attachments(&self) -> Vec<compose::Attachment> {
4221 self.draft()
4222 .and_then(|c| draft_full(c).ok())
4223 .map(|full| compose::extract_attachments(&full).1)
4224 .unwrap_or_default()
4225 }
4226
4227 pub fn edit_attachment(&mut self, k: usize, f: impl Fn(&mut compose::Attachment)) {
4229 self.edit_draft_head(|head| {
4230 let mut seen = 0usize;
4231 head.lines()
4232 .map(|l| {
4233 let is_attach = l
4234 .split_once(':')
4235 .is_some_and(|(key, _)| key.trim().eq_ignore_ascii_case("attach"));
4236 if is_attach {
4237 let (_, mut atts) = compose::extract_attachments(l);
4238 if let Some(mut a) = atts.pop() {
4239 let idx = seen;
4240 seen += 1;
4241 if idx == k {
4242 f(&mut a);
4243 return compose::attach_line(&a);
4244 }
4245 }
4246 }
4247 l.to_string()
4248 })
4249 .collect::<Vec<_>>()
4250 .join("\n")
4251 });
4252 }
4253
4254 pub fn toggle_unlink(&mut self, sel: usize) {
4257 let Some(k) = self.attach_index(sel) else {
4258 return;
4259 };
4260 self.edit_attachment(k, |a| a.unlink = !a.unlink);
4261 let on = self.attachments().get(k).is_some_and(|a| a.unlink);
4262 self.note(if on {
4263 "the file will be deleted after sending"
4264 } else {
4265 "the file will be kept after sending"
4266 });
4267 }
4268
4269 pub fn toggle_disposition(&mut self, sel: usize) {
4271 let Some(k) = self.attach_index(sel) else {
4272 return;
4273 };
4274 self.edit_attachment(k, |a| a.inline = !a.inline);
4275 }
4276
4277 pub fn move_attachment(&mut self, sel: usize, up: bool) -> bool {
4280 let Some(k) = self.attach_index(sel) else {
4281 return false;
4282 };
4283 let n = self.attachments().len();
4284 let other = if up {
4285 k.checked_sub(1)
4286 } else {
4287 (k + 1 < n).then_some(k + 1)
4288 };
4289 let Some(other) = other else { return false };
4290 self.edit_draft_head(|head| {
4291 let mut lines: Vec<String> = head.lines().map(String::from).collect();
4292 let attach_rows: Vec<usize> = lines
4293 .iter()
4294 .enumerate()
4295 .filter(|(_, l)| {
4296 l.split_once(':')
4297 .is_some_and(|(key, _)| key.trim().eq_ignore_ascii_case("attach"))
4298 })
4299 .map(|(i, _)| i)
4300 .collect();
4301 if let (Some(&a), Some(&b)) = (attach_rows.get(k), attach_rows.get(other)) {
4302 lines.swap(a, b);
4303 }
4304 lines.join("\n")
4305 });
4306 true
4307 }
4308
4309 fn push_attach_line(&mut self, line: &str) -> std::io::Result<()> {
4311 let Some(c) = self.draft_mut() else {
4312 return Ok(());
4313 };
4314 match &mut c.hidden_head {
4315 Some(head) => {
4316 *head = format!("{}\n{line}", head.trim_end());
4317 Ok(())
4318 }
4319 None => std::fs::read_to_string(&c.path).and_then(|text| {
4320 let updated = match text.split_once("\n\n") {
4321 Some((head, body)) => format!("{head}\n{line}\n\n{body}"),
4322 None => format!("{}\n{line}\n", text.trim_end()),
4323 };
4324 std::fs::write(&c.path, updated)
4325 }),
4326 }
4327 }
4328
4329 pub fn attach_messages(&mut self) {
4335 if self.draft().is_none() {
4336 return;
4337 }
4338 let tagged = self.msgs.iter().any(|m| m.env.tagged);
4339 let paths = self.target_paths(tagged);
4340 let mut attached = 0usize;
4341 for path in paths {
4342 if remote::is_partial(&path) {
4343 self.error(format!(
4344 "{} is not fetched yet: open it first",
4345 path.file_name().and_then(|n| n.to_str()).unwrap_or("?")
4346 ));
4347 continue;
4348 }
4349 let mut a = compose::Attachment::of(path);
4350 a.mime = Some("message/rfc822".into());
4351 a.inline = true;
4352 let line = compose::attach_line(&a);
4353 match self.push_attach_line(&line) {
4354 Ok(()) => attached += 1,
4355 Err(err) => self.error(format!("cannot attach: {err}")),
4356 }
4357 }
4358 if attached > 0 {
4359 self.note(format!("attached {attached} message(s)"));
4360 }
4361 self.requests.push(Request::ShowDraft);
4362 }
4363
4364 pub fn new_mime(&mut self, path: &str, mime: &str) {
4368 if mime.is_empty() {
4369 self.requests.push(Request::ShowDraft);
4370 return;
4371 }
4372 if !mime.contains('/') || mime.starts_with('/') || mime.ends_with('/') {
4373 self.error("Content-Type is of the form base/sub");
4374 self.requests.push(Request::ShowDraft);
4375 return;
4376 }
4377 let file = expand_tilde(path);
4378 if !file.exists()
4379 && let Err(err) = std::fs::write(&file, b"")
4380 {
4381 self.error(format!("cannot create {}: {err}", file.display()));
4382 self.requests.push(Request::ShowDraft);
4383 return;
4384 }
4385 let mut a = compose::Attachment::of(file.clone());
4386 a.mime = Some(mime.to_string());
4387 let line = compose::attach_line(&a);
4388 if let Err(err) = self.push_attach_line(&line) {
4389 self.error(format!("cannot attach: {err}"));
4390 } else {
4391 self.requests.push(Request::EditFile(file));
4392 }
4393 self.requests.push(Request::ShowDraft);
4394 }
4395
4396 pub fn ispell_command(&self) -> Option<String> {
4399 let path = self.draft()?.path.display().to_string();
4400 let ispell = self.config.mail.ispell.as_deref().unwrap_or("ispell");
4401 Some(format!("{ispell} -x '{}'", path.replace('\'', "'\\''")))
4402 }
4403
4404 fn outgoing_text(&self, c: &Compose) -> Result<String> {
4407 let raw = draft_full(c).context("reading the draft")?;
4408 let (raw, files) = compose::extract_attachments(&raw);
4409 let host = maildir::hostname();
4410 let from = self
4411 .current_identity(&[])
4412 .from_line()
4413 .unwrap_or_else(|| default_from(&host));
4414 let msg_host = self
4415 .config
4416 .mail
4417 .hostname
4418 .clone()
4419 .filter(|h| !h.trim().is_empty())
4420 .unwrap_or(host);
4421 let text = compose::finalize_with(
4422 &raw,
4423 &from,
4424 &compose::make_message_id(&msg_host),
4425 &compose::rfc2822_now(),
4426 self.config.mail.user_agent.unwrap_or(false),
4427 )
4428 .map_err(|e| anyhow::anyhow!("{e}"))?;
4429 let original = match &c.attach {
4430 Some(path) => Some(std::fs::read(path).context("reading the original")?),
4431 None => None,
4432 };
4433 self.secure_message(c.security, text, &files, original.as_deref())
4434 }
4435
4436 pub fn write_draft_to(&mut self, mailbox: &str) {
4440 let Some(c) = self.draft() else { return };
4441 let text = match self.outgoing_text(c) {
4442 Ok(t) => t,
4443 Err(err) => {
4444 self.error(format!("cannot write the message: {err:#}"));
4445 return;
4446 }
4447 };
4448 let flags = maildir::Flags {
4449 seen: true,
4450 ..Default::default()
4451 };
4452 match remote::parse_spec(mailbox) {
4453 Some((account, folder)) => {
4454 let Some(imap) = self
4455 .imap
4456 .as_mut()
4457 .filter(|i| i.facts.account.name == account)
4458 else {
4459 self.error("write-fcc to IMAP needs a folder of the open account");
4460 return;
4461 };
4462 match imap.blocking(Job::Append {
4463 mailbox: Some(folder.to_string()),
4464 flags,
4465 body: text.into_bytes(),
4466 }) {
4467 Ok(_) => self.note(format!("Message written to {mailbox}.")),
4468 Err(err) => self.error(format!("write failed: {err:#}")),
4469 }
4470 }
4471 None => {
4472 let dir = expand_tilde(mailbox);
4473 if !dir.join("cur").is_dir() {
4474 self.error(format!("{mailbox} is not a maildir"));
4475 return;
4476 }
4477 match maildir::deliver(&dir, text.as_bytes(), flags) {
4478 Ok(_) => self.note(format!("Message written to {mailbox}.")),
4479 Err(err) => self.error(format!("write failed: {err:#}")),
4480 }
4481 }
4482 }
4483 }
4484
4485 pub fn detach(&mut self, sel: usize) {
4486 let fixed = 1 + usize::from(self.draft().is_some_and(|c| c.attach.is_some()));
4487 if sel < fixed {
4488 self.error("only Attach: files can be detached");
4489 return;
4490 }
4491 let k = sel - fixed;
4492 self.edit_draft_head(|head| {
4493 let mut seen = 0usize;
4494 head.lines()
4495 .filter(|l| {
4496 let is_attach = l
4497 .split_once(':')
4498 .is_some_and(|(key, _)| key.trim().eq_ignore_ascii_case("attach"));
4499 if is_attach {
4500 seen += 1;
4501 seen - 1 != k
4502 } else {
4503 true
4504 }
4505 })
4506 .collect::<Vec<_>>()
4507 .join("\n")
4508 });
4509 }
4510
4511 pub fn attach_file(&mut self, input: &str) {
4515 let input = input.trim();
4516 if !input.is_empty() {
4517 if !expand_tilde(input).is_file() {
4518 self.error(format!("{input} is not a file"));
4519 } else if let Some(c) = self.draft_mut() {
4520 let value = if input.contains(char::is_whitespace) && !input.starts_with('"') {
4523 format!("\"{input}\"")
4524 } else {
4525 input.to_string()
4526 };
4527 let result = match &mut c.hidden_head {
4528 Some(head) => {
4530 *head = format!("{}\nAttach: {value}", head.trim_end());
4531 Ok(())
4532 }
4533 None => std::fs::read_to_string(&c.path).and_then(|text| {
4534 let updated = match text.split_once("\n\n") {
4535 Some((head, body)) => format!("{head}\nAttach: {value}\n\n{body}"),
4536 None => format!("{}\nAttach: {value}\n", text.trim_end()),
4537 };
4538 std::fs::write(&c.path, updated)
4539 }),
4540 };
4541 if let Err(err) = result {
4542 self.error(format!("cannot attach: {err}"));
4543 }
4544 }
4545 }
4546 self.requests.push(Request::ShowDraft);
4547 }
4548
4549 pub fn error(&mut self, msg: impl Into<String>) {
4550 self.notify(Notice::Error(msg.into()));
4551 }
4552
4553 pub fn note(&mut self, msg: impl Into<String>) {
4555 self.notify(Notice::Info(msg.into()));
4556 }
4557
4558 fn notify(&mut self, notice: Notice) {
4563 if let Notice::Error(text) = ¬ice {
4564 let keep = self.config.ui.error_history;
4565 self.error_history.push_back(text.clone());
4566 while self.error_history.len() > keep {
4567 self.error_history.pop_front();
4568 }
4569 }
4570 if (notice.is_error() && self.config.ui.beep)
4573 || (notice.is_new_mail() && self.config.ui.beep_new)
4574 {
4575 use std::io::Write as _;
4576 let mut out = std::io::stdout();
4577 let _ = out.write_all(b"\x07");
4578 let _ = out.flush();
4579 }
4580 self.notices.notice(notice);
4581 }
4582
4583 pub fn notice(&self) -> Option<&Notice> {
4586 self.notices.latest()
4587 }
4588}
4589
4590fn subject_key(subject: &str) -> String {
4592 let mut key = subject.trim().to_lowercase();
4593 loop {
4594 let stripped = key
4595 .strip_prefix("re:")
4596 .or_else(|| key.strip_prefix("fwd:"))
4597 .or_else(|| key.strip_prefix("fw:"))
4598 .map(|rest| rest.trim_start().to_string());
4599 match stripped {
4600 Some(next) => key = next,
4601 None => break,
4602 }
4603 }
4604 key
4605}
4606
4607pub fn parse_sort(spec: &str) -> Option<(SortKey, bool)> {
4609 let (rev, name) = match spec.strip_prefix("reverse-") {
4610 Some(rest) => (true, rest),
4611 None => (false, spec),
4612 };
4613 let key = match name {
4614 "date" | "date-sent" | "date-received" => SortKey::Date,
4615 "from" => SortKey::From,
4616 "subject" => SortKey::Subject,
4617 "size" => SortKey::Size,
4618 "threads" => SortKey::Threads,
4619 "label" => SortKey::Label,
4620 "to" => SortKey::To,
4621 "unsorted" | "mailbox-order" => SortKey::Unsorted,
4622 _ => return None,
4623 };
4624 Some((key, rev && key != SortKey::Threads))
4626}
4627
4628impl pattern::EnvSource for Session {
4629 fn envelope(&self, i: usize) -> Option<&Envelope> {
4630 self.msgs.get(i).map(|m| &m.env)
4631 }
4632}
4633
4634fn dir_mtimes(dir: &Path) -> (Option<SystemTime>, Option<SystemTime>) {
4635 let mtime = |p: PathBuf| p.metadata().and_then(|m| m.modified()).ok();
4636 (mtime(dir.join("new")), mtime(dir.join("cur")))
4637}
4638
4639pub fn account_password(account: &Account) -> Result<String> {
4642 if !matches!(account.auth_kind()?, rmut_core::config::AuthKind::Password) {
4643 return account.secret();
4644 }
4645 static CACHE: Mutex<Option<HashMap<String, String>>> = Mutex::new(None);
4646 let mut cache = CACHE.lock().unwrap();
4647 let map = cache.get_or_insert_with(HashMap::new);
4648 if let Some(password) = map.get(&account.name) {
4649 return Ok(password.clone());
4650 }
4651 let password = account.password()?;
4652 map.insert(account.name.clone(), password.clone());
4653 Ok(password)
4654}
4655
4656pub fn wrap_order(n: usize, sel: usize, forward: bool) -> Vec<(usize, bool)> {
4661 (1..=n)
4662 .map(|step| {
4663 if forward {
4664 ((sel + step) % n, sel + step >= n)
4665 } else {
4666 ((sel + n - (step % n)) % n, step > sel)
4667 }
4668 })
4669 .collect()
4670}
4671
4672pub fn sort_browser(dirs: &mut [(String, usize)], how: Option<&str>) {
4680 let how = how.unwrap_or("alpha");
4681 let (reverse, key) = match how.strip_prefix("reverse-") {
4682 Some(key) => (true, key),
4683 None => (false, how),
4684 };
4685 match key {
4686 "unsorted" => {}
4687 "count" | "unread" => dirs.sort_by_key(|(_, count)| *count),
4688 "date" => dirs.sort_by_cached_key(|(spec, _)| {
4689 if spec.starts_with("imap:") {
4690 return std::time::SystemTime::UNIX_EPOCH;
4691 }
4692 let dir = expand_tilde(spec);
4693 ["new", "cur"]
4694 .iter()
4695 .filter_map(|sub| std::fs::metadata(dir.join(sub)).ok())
4696 .filter_map(|m| m.modified().ok())
4697 .max()
4698 .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
4699 }),
4700 _ => dirs.sort_by(|a, b| a.0.cmp(&b.0)),
4701 }
4702 if reverse {
4703 dirs.reverse();
4704 }
4705}
4706
4707pub fn expand_tilde(input: &str) -> PathBuf {
4708 if let Some(rest) = input.strip_prefix("~/")
4709 && let Ok(home) = std::env::var("HOME")
4710 {
4711 return Path::new(&home).join(rest);
4712 }
4713 PathBuf::from(input)
4714}
4715
4716pub fn send_via_smtp(account: &Account, text: &str) -> Result<()> {
4717 let from = compose::from_address(text).context("cannot parse the From address")?;
4718 let (rcpts, text) = compose::smtp_envelope(text)?;
4719 anyhow::ensure!(!rcpts.is_empty(), "no recipient addresses");
4720 let password = account_password(account)?;
4721 smtp::send(account, &password, &from, &rcpts, text.as_bytes())
4722}
4723
4724pub fn pipe_to(command: &str, bytes: &[u8]) -> Result<()> {
4726 let mut child = Command::new("sh")
4727 .arg("-c")
4728 .arg(command)
4729 .stdin(Stdio::piped())
4730 .stdout(Stdio::null())
4731 .stderr(Stdio::null())
4732 .spawn()
4733 .with_context(|| format!("running {command}"))?;
4734 {
4738 let mut stdin = child.stdin.take().context("no stdin on the child")?;
4739 if let Err(err) = stdin.write_all(bytes)
4740 && err.kind() != std::io::ErrorKind::BrokenPipe
4741 {
4742 return Err(err.into());
4743 }
4744 }
4745 let status = child.wait()?;
4746 anyhow::ensure!(status.success(), "{command} exited with {status}");
4747 Ok(())
4748}
4749
4750pub fn run_sendmail(
4753 bytes: &[u8],
4754 configured: Option<&str>,
4755 rcpts: Option<&[String]>,
4756) -> Result<()> {
4757 let command = std::env::var("RMUT_SENDMAIL")
4758 .ok()
4759 .or_else(|| configured.map(String::from));
4760 let (prog, mut args) = match command {
4761 Some(v) => {
4762 let mut it = v.split_whitespace().map(String::from);
4763 let p = it.next().context("sendmail command is empty")?;
4764 (p, it.collect::<Vec<_>>())
4765 }
4766 None => {
4767 let p = if Path::new("/usr/sbin/sendmail").exists() {
4768 "/usr/sbin/sendmail".to_string()
4769 } else {
4770 "sendmail".to_string()
4771 };
4772 (p, Vec::new())
4773 }
4774 };
4775 match rcpts {
4776 Some(rcpts) => {
4777 args.push("-oi".into());
4778 args.extend(rcpts.iter().cloned());
4779 }
4780 None => args.extend(["-t".into(), "-oi".into()]),
4781 }
4782 let mut child = Command::new(&prog)
4783 .args(&args)
4784 .stdin(Stdio::piped())
4785 .stdout(Stdio::null())
4786 .stderr(Stdio::null())
4787 .spawn()
4788 .with_context(|| format!("running {prog}"))?;
4789 child
4790 .stdin
4791 .take()
4792 .context("no stdin on sendmail child")?
4793 .write_all(bytes)?;
4794 let status = child.wait()?;
4795 anyhow::ensure!(status.success(), "{prog} exited with {status}");
4796 Ok(())
4797}
4798
4799pub fn default_from(hostname: &str) -> String {
4800 if let Ok(email) = std::env::var("EMAIL") {
4801 return email;
4802 }
4803 let user = std::env::var("USER").unwrap_or_else(|_| "user".into());
4804 format!("{user}@{hostname}")
4805}
4806
4807const DEFAULT_ATTACH_KEYWORD: &str = r"\b(attach|attached|attaching|attachment|attachments)\b";
4810
4811fn attach_re_from_config(config: &Config, warnings: &mut Vec<String>) -> regex_lite::Regex {
4814 let spec = config
4815 .mail
4816 .attach_keyword
4817 .clone()
4818 .unwrap_or_else(|| DEFAULT_ATTACH_KEYWORD.to_string());
4819 match regex_lite::Regex::new(&format!("(?i){spec}")) {
4820 Ok(re) => re,
4821 Err(err) => {
4822 warnings.push(format!("bad attach_keyword {spec:?}: {err}"));
4823 default_attach_re()
4824 }
4825 }
4826}
4827
4828fn default_attach_re() -> regex_lite::Regex {
4829 regex_lite::Regex::new(&format!("(?i){DEFAULT_ATTACH_KEYWORD}")).expect("the default compiles")
4830}
4831
4832fn reply_re_from_config(config: &Config, warnings: &mut Vec<String>) -> regex_lite::Regex {
4835 match &config.mail.reply_regexp {
4836 Some(spec) => match compose::reply_regexp(spec) {
4837 Ok(re) => re,
4838 Err(err) => {
4839 warnings.push(format!("bad reply_regexp {spec:?}: {err}"));
4840 compose::default_reply_regexp()
4841 }
4842 },
4843 None => compose::default_reply_regexp(),
4844 }
4845}
4846
4847fn quote_re_from_config(config: &Config, warnings: &mut Vec<String>) -> regex_lite::Regex {
4850 match &config.pager.quote_regexp {
4851 Some(spec) => match regex_lite::Regex::new(spec) {
4852 Ok(re) => re,
4853 Err(err) => {
4854 warnings.push(format!("bad quote_regexp {spec:?}: {err}"));
4855 default_quote_re()
4856 }
4857 },
4858 None => default_quote_re(),
4859 }
4860}
4861
4862fn display_from_config(config: &Config) -> message::Display {
4865 let mut rules = message::HeaderRules::default();
4869 let clean = |list: &Vec<String>| {
4870 list.iter()
4871 .map(|n| n.trim_end_matches(':').to_lowercase())
4872 .collect::<Vec<_>>()
4873 };
4874 if let Some(list) = &config.pager.ignore {
4875 rules.ignore = clean(list);
4876 }
4877 if let Some(list) = &config.pager.unignore {
4878 rules.unignore = clean(list);
4879 }
4880 if let Some(list) = &config.pager.hdr_order {
4881 rules.order = clean(list);
4882 }
4883 let mut filters: HashMap<String, String> = HashMap::new();
4888 let mut mailcap: Option<Vec<rmut_core::mailcap::Entry>> = None;
4889 for (mimetype, command) in &config.filters {
4890 let mimetype = mimetype.to_lowercase();
4891 if !command.trim().is_empty() {
4892 filters.insert(mimetype, command.clone());
4893 continue;
4894 }
4895 let entries = mailcap.get_or_insert_with(rmut_core::mailcap::load);
4896 if let Some(command) = rmut_core::mailcap::command_for(entries, &mimetype) {
4897 filters.insert(mimetype, command);
4898 }
4899 }
4900 message::Display {
4901 filters,
4902 rules,
4903 reflow: config.pager.reflow_text.unwrap_or(true),
4904 alternative_order: config
4905 .pager
4906 .alternative_order
4907 .iter()
4908 .map(|t| t.to_lowercase())
4909 .collect(),
4910 html_to_text: config.pager.html.as_deref() != Some("raw"),
4911 }
4912}
4913
4914fn compile_hooks<'a>(
4917 what: &str,
4918 entries: impl Iterator<Item = (&'a String, &'a String)>,
4919 warnings: &mut Vec<String>,
4920) -> Vec<Hook> {
4921 let mut out = Vec::new();
4922 for (spec, value) in entries {
4923 if value.trim().is_empty() {
4924 warnings.push(format!("{what} {spec:?} has nothing to do"));
4925 continue;
4926 }
4927 match pattern::parse(spec) {
4928 Ok(patterns) => out.push(Hook {
4929 patterns,
4930 value: value.clone(),
4931 }),
4932 Err(err) => warnings.push(format!("bad {what} pattern {spec:?}: {err}")),
4933 }
4934 }
4935 out
4936}
4937
4938pub fn header_value(head: &str, name: &str) -> Option<String> {
4940 head.lines().find_map(|l| {
4941 let (k, v) = l.split_once(':')?;
4942 k.trim()
4943 .eq_ignore_ascii_case(name)
4944 .then(|| v.trim().to_string())
4945 })
4946}
4947
4948pub fn draft_full(c: &Compose) -> std::io::Result<String> {
4951 let text = std::fs::read_to_string(&c.path)?;
4952 Ok(match &c.hidden_head {
4953 Some(head) => format!("{}\n\n{}", head.trim_end(), text),
4954 None => text,
4955 })
4956}
4957
4958pub fn default_quote_re() -> regex_lite::Regex {
4960 regex_lite::Regex::new(r"^([ \t]*[|>:}#])+").expect("default quote_regexp compiles")
4961}
4962
4963pub fn write_draft(text: &str) -> Result<PathBuf> {
4964 use std::sync::atomic::{AtomicUsize, Ordering};
4965 static COUNTER: AtomicUsize = AtomicUsize::new(0);
4966 let path = std::env::temp_dir().join(format!(
4967 "rmut-draft-{}-{}.eml",
4968 std::process::id(),
4969 COUNTER.fetch_add(1, Ordering::Relaxed),
4970 ));
4971 std::fs::write(&path, text).with_context(|| format!("writing {}", path.display()))?;
4972 Ok(path)
4973}