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_parent: Vec<Option<usize>>,
352 pub thread_children: Vec<Vec<usize>>,
353 pub thread_members: Vec<Vec<usize>>,
354 pub collapsed: HashSet<PathBuf>,
356 undo: Vec<UndoStep>,
359 pub config: Config,
360 pub my_addresses: Vec<String>,
363 pub lists: Vec<pattern::Matcher>,
366 pub subscribed: Vec<pattern::Matcher>,
369 pub alternates: Vec<pattern::Matcher>,
372 body_hits: HashMap<String, HashSet<u32>>,
375 pub idle: Option<remote::IdleWatch>,
377 backfill: Option<remote::Backfill>,
379 mailbox_new: HashMap<String, usize>,
382 error_history: std::collections::VecDeque<String>,
385 mailbox_announced: HashSet<String>,
388 unseen: HashMap<String, usize>,
391 pub read_only: bool,
393 pub read_only_session: bool,
397 pub(crate) dir_mtimes: (Option<SystemTime>, Option<SystemTime>),
399 pub message_hooks: Vec<Hook>,
404 pub reply_hooks: Vec<Hook>,
405 fcc_hooks: Vec<Hook>,
406 crypt_hooks: Vec<(pattern::Matcher, String)>,
407 pub display: message::Display,
410 outbox: Vec<Held>,
413 pub quote_re: regex_lite::Regex,
416 pub reply_re: regex_lite::Regex,
418 attach_re: regex_lite::Regex,
420 pending: Option<Pending>,
423 deferred: Option<Again>,
427 progress_noted: bool,
430 aborted: bool,
433 quit_default: bool,
436 setup: Option<ComposeSetup>,
439 draft: Option<Compose>,
443 staged: Option<(PathBuf, String)>,
447 attach_confirmed: bool,
450 hook_base: Option<Box<Config>>,
453 active_message_hooks: Vec<usize>,
456 requests: Vec<Request>,
458 notices: Box<dyn NoticeSink>,
461}
462
463struct Silence;
465
466impl NoticeSink for Silence {
467 fn notice(&mut self, _notice: Notice) {}
468
469 fn latest(&self) -> Option<&Notice> {
470 None
471 }
472
473 fn clear(&mut self) {}
474}
475
476impl Session {
477 pub fn open(dir: &Path, config: Config) -> Result<(Session, Vec<String>)> {
483 let (envelopes, skipped) = hdrcache::load_envelopes(dir)?;
485 let mut msgs: Vec<Msg> = envelopes
486 .into_iter()
487 .map(|env| Msg {
488 env,
489 dirty: false,
490 purge: false,
491 })
492 .collect();
493 msgs.sort_by_key(|m| m.env.date);
495 let visible: Vec<usize> = (0..msgs.len()).collect();
496 let sel = msgs
498 .iter()
499 .position(|m| m.env.file.is_new)
500 .unwrap_or(visible.len().saturating_sub(1));
501 let mut warnings = Vec::new();
502 if skipped > 0 {
503 warnings.push(format!("{skipped} unreadable message(s) skipped"));
504 }
505 let count = msgs.len();
506 let mut me: Vec<String> = config
507 .identity
508 .email
509 .iter()
510 .chain(config.accounts.iter().map(|a| &a.user))
511 .chain(
512 config
513 .accounts
514 .iter()
515 .filter_map(|a| a.identity.as_ref().and_then(|i| i.email.as_ref())),
516 )
517 .chain(config.identities.iter().filter_map(|r| r.email.as_ref()))
518 .map(|a| a.to_lowercase())
519 .collect();
520 if let Ok(email) = std::env::var("EMAIL") {
521 me.push(email.to_lowercase());
522 }
523 let mut session = Session {
524 dir: dir.to_path_buf(),
525 title: dir.display().to_string(),
526 imap: None,
527 mbox: None,
528 msgs,
529 visible,
530 sel,
531 sort: SortKey::Date,
532 sort_rev: false,
533 limit: None,
534 last_search: None,
535 search_rev: false,
536 thread_depth: vec![0; count],
537 thread_root: (0..count).collect(),
538 thread_parent: vec![None; count],
539 thread_children: vec![Vec::new(); count],
540 thread_members: (0..count).map(|i| vec![i]).collect(),
541 collapsed: HashSet::new(),
542 undo: Vec::new(),
543 lists: config.list_matchers(),
544 subscribed: config.subscribed_matchers(),
545 alternates: config.alternate_matchers(),
546 my_addresses: me,
547 body_hits: HashMap::new(),
548 idle: None,
549 backfill: None,
550 mailbox_new: HashMap::new(),
551 error_history: std::collections::VecDeque::new(),
552 mailbox_announced: HashSet::new(),
553 unseen: HashMap::new(),
554 read_only: false,
555 read_only_session: false,
556 dir_mtimes: dir_mtimes(dir),
557 message_hooks: Vec::new(),
558 reply_hooks: Vec::new(),
559 fcc_hooks: Vec::new(),
560 crypt_hooks: Vec::new(),
561 display: display_from_config(&config),
562 outbox: Vec::new(),
563 quote_re: default_quote_re(),
564 reply_re: compose::default_reply_regexp(),
565 attach_re: default_attach_re(),
566 config,
567 pending: None,
568 deferred: None,
569 progress_noted: false,
570 aborted: false,
571 quit_default: true,
572 setup: None,
573 draft: None,
574 staged: None,
575 attach_confirmed: false,
576 hook_base: None,
577 active_message_hooks: Vec::new(),
578 requests: Vec::new(),
579 notices: Box::new(Silence),
580 };
581 session.apply_timeouts();
582 let mut hook_warnings = Vec::new();
583 session.quote_re = quote_re_from_config(&session.config, &mut hook_warnings);
584 session.reply_re = reply_re_from_config(&session.config, &mut hook_warnings);
585 session.attach_re = attach_re_from_config(&session.config, &mut hook_warnings);
586 session.compile_hooks_from_config(&mut hook_warnings);
587 hook_warnings.append(&mut warnings);
588 let mut warnings = hook_warnings;
589 if let Some(spec) = session.config.index.sort.clone() {
590 match parse_sort(&spec) {
591 Some((sort, rev)) => {
592 session.sort = sort;
593 session.sort_rev = rev;
594 session.resort(None);
595 }
596 None => warnings.push(format!("unknown sort {spec:?} in config")),
597 }
598 }
599 Ok((session, warnings))
600 }
601
602 pub fn open_spec(
609 spec: &str,
610 config: Config,
611 progress: remote::Progress,
612 ) -> Result<(Session, Vec<String>)> {
613 rmut_core::net::set_timeouts(config.net.connect_timeout, config.net.timeout);
616 rmut_core::net::set_trust(
617 config.net.system_cas,
618 config.net.certificate_file.as_deref().map(expand_tilde),
619 );
620 match remote::parse_spec(spec) {
621 Some((account_name, mailbox)) => {
622 let account = config
623 .account(account_name)
624 .with_context(|| format!("no account {account_name} in config"))?
625 .clone();
626 let password = account_password(&account)?;
627 let remote = Remote::open(&account, mailbox, &password, progress)?;
628 let cache = remote.cache.clone();
629 let (mut session, warnings) = Session::open(&cache, config)?;
630 session.title = remote.spec.clone();
631 session.idle = Some(remote::idle_watch(&account, &remote.mailbox, &password));
634 session.imap = Some(Imap::new(remote));
635 Ok((session, warnings))
636 }
637 None => {
638 let path = expand_tilde(spec);
639 if path.is_file() {
640 return Session::open_mbox(&path, config);
641 }
642 Session::open(&path, config)
643 }
644 }
645 }
646
647 pub fn open_mbox(path: &Path, config: Config) -> Result<(Session, Vec<String>)> {
650 let mbox = mbox::Mbox::open(path)?;
651 let cache = mbox.cache.clone();
652 let (mut session, warnings) = Session::open(&cache, config)?;
653 session.title = path.display().to_string();
654 session.mbox = Some(mbox);
655 Ok((session, warnings))
656 }
657
658 pub fn recompile(&mut self) -> Vec<String> {
661 let mut warnings = Vec::new();
662 self.apply_timeouts();
663 self.display = display_from_config(&self.config);
664 self.quote_re = quote_re_from_config(&self.config, &mut warnings);
665 self.reply_re = reply_re_from_config(&self.config, &mut warnings);
666 self.attach_re = attach_re_from_config(&self.config, &mut warnings);
667 self.compile_hooks_from_config(&mut warnings);
668 self.lists = self.config.list_matchers();
669 self.subscribed = self.config.subscribed_matchers();
670 self.alternates = self.config.alternate_matchers();
671 warnings
672 }
673
674 fn apply_timeouts(&self) {
678 rmut_core::net::set_timeouts(self.config.net.connect_timeout, self.config.net.timeout);
679 rmut_core::net::set_trust(
680 self.config.net.system_cas,
681 self.config
682 .net
683 .certificate_file
684 .as_deref()
685 .map(expand_tilde),
686 );
687 }
688
689 fn compile_hooks_from_config(&mut self, warnings: &mut Vec<String>) {
692 self.message_hooks = compile_hooks(
693 "message-hook",
694 self.config
695 .message_hooks
696 .iter()
697 .map(|h| (&h.pattern, &h.command)),
698 warnings,
699 );
700 self.reply_hooks = compile_hooks(
701 "reply-hook",
702 self.config
703 .reply_hooks
704 .iter()
705 .map(|h| (&h.pattern, &h.command)),
706 warnings,
707 );
708 self.fcc_hooks = compile_hooks(
709 "fcc-hook",
710 self.config
711 .fcc_hooks
712 .iter()
713 .map(|h| (&h.pattern, &h.mailbox)),
714 warnings,
715 );
716 self.crypt_hooks = self
717 .config
718 .crypt_hooks
719 .iter()
720 .map(|h| (pattern::Matcher::new(&h.address), h.key.clone()))
721 .collect();
722 }
723
724 pub fn switch_to(&mut self, spec: &str, progress: remote::Progress) -> Result<Vec<String>> {
734 self.settle();
738 let reuse = match remote::parse_spec(spec) {
739 Some((account, mailbox))
740 if self
741 .imap
742 .as_ref()
743 .is_some_and(|imap| imap.facts.account.name == account)
744 && self
745 .imap
746 .as_mut()
747 .is_some_and(|imap| imap.blocking(Job::Switch(mailbox.into())).is_ok()) =>
748 {
749 self.imap.take()
750 }
751 _ => None,
752 };
753 let config = self.config.clone();
754 let (mut next, warnings) = match &reuse {
755 Some(imap) => Session::open(&imap.facts.cache.clone(), config)?,
756 None => Session::open_spec(spec, config, progress)?,
757 };
758 if let Some(imap) = reuse {
759 next.title = imap.facts.spec.clone();
760 if let Ok(password) = account_password(&imap.facts.account) {
762 next.idle = Some(remote::idle_watch(
763 &imap.facts.account,
764 &imap.facts.mailbox,
765 &password,
766 ));
767 }
768 next.imap = Some(imap);
769 }
770 next.read_only_session = self.read_only_session;
773 next.read_only = self.read_only_session;
774 next.notices = mem::replace(&mut self.notices, Box::new(Silence));
775 *self = next;
776 Ok(warnings)
777 }
778
779 pub fn install_notices(&mut self, sink: Box<dyn NoticeSink>) {
782 self.notices = sink;
783 }
784
785 pub fn clear_notice(&mut self) {
788 self.notices.clear();
789 }
790
791 pub fn new_count(&self) -> usize {
792 self.msgs.iter().filter(|m| m.env.file.is_new).count()
793 }
794
795 pub fn deleted_count(&self) -> usize {
796 self.msgs
797 .iter()
798 .filter(|m| m.env.file.flags.deleted)
799 .count()
800 }
801
802 pub fn pending_count(&self) -> usize {
803 self.msgs.iter().filter(|m| m.pending()).count()
804 }
805
806 fn thread_has_unread(&self, root: usize) -> bool {
808 self.thread_root
809 .iter()
810 .enumerate()
811 .filter(|&(_, &r)| r == root)
812 .any(|(i, _)| !self.msgs[i].env.file.flags.seen)
813 }
814
815 fn first_unread_in_thread(&self, root: usize) -> Option<PathBuf> {
818 self.thread_root
819 .iter()
820 .enumerate()
821 .filter(|&(_, &r)| r == root)
822 .map(|(i, _)| i)
823 .find(|&i| !self.msgs[i].env.file.flags.seen)
824 .map(|i| self.msgs[i].env.file.path.clone())
825 }
826
827 pub fn thread_info(&self, mi: usize) -> (usize, Option<usize>) {
829 if self.sort != SortKey::Threads {
830 return (0, None);
831 }
832 let depth = self.thread_depth.get(mi).copied().unwrap_or(0);
833 if depth == 0 && self.collapsed.contains(&self.msgs[mi].env.file.path) {
834 let hidden = self
835 .thread_root
836 .iter()
837 .enumerate()
838 .filter(|&(j, &r)| r == mi && j != mi)
839 .count();
840 if hidden > 0 {
841 return (0, Some(hidden));
842 }
843 }
844 (depth, None)
845 }
846
847 pub fn select(&mut self, index: usize) {
848 if self.visible.is_empty() {
849 return;
850 }
851 self.sel = index.min(self.visible.len() - 1);
852 }
853
854 pub fn cur_mut(&mut self) -> Option<&mut Msg> {
855 let i = self.visible.get(self.sel).copied()?;
856 self.msgs.get_mut(i)
857 }
858
859 pub fn selected_path(&self) -> Option<PathBuf> {
860 self.visible
861 .get(self.sel)
862 .map(|&i| self.msgs[i].env.file.path.clone())
863 }
864
865 pub fn deny_readonly(&mut self) -> bool {
868 if self.read_only {
869 self.error("Mailbox is read-only.");
870 }
871 self.read_only
872 }
873
874 pub fn mark_read(&mut self) {
875 if self.read_only {
876 return;
877 }
878 if let Some(m) = self.cur_mut()
879 && (m.env.file.is_new || !m.env.file.flags.seen)
880 {
881 m.env.file.is_new = false;
882 m.env.file.flags.seen = true;
883 m.dirty = true;
884 }
885 }
886
887 pub fn scope(&self, position: pattern::Position) -> pattern::Scope<'_> {
890 pattern::Scope {
891 me: self.me(),
892 lists: &self.lists,
893 subscribed: &self.subscribed,
894 position,
895 thread: None,
896 }
897 }
898
899 pub fn scope_at(&self, position: pattern::Position, mi: usize) -> pattern::Scope<'_> {
903 let thread = (self.sort == SortKey::Threads && mi < self.msgs.len()).then(|| {
904 let root = self.thread_root.get(mi).copied().unwrap_or(mi);
905 pattern::ThreadView {
906 members: self.thread_members.get(root).map_or(&[][..], Vec::as_slice),
907 parent: self.thread_parent.get(mi).copied().flatten(),
908 children: self.thread_children.get(mi).map_or(&[][..], Vec::as_slice),
909 collapsed: self.thread_depth.get(mi) == Some(&0)
910 && self.collapsed.contains(&self.msgs[mi].env.file.path),
911 envs: self,
912 }
913 });
914 pattern::Scope {
915 thread,
916 ..self.scope(position)
917 }
918 }
919
920 pub fn me(&self) -> pattern::Me<'_> {
922 pattern::Me::new(&self.my_addresses, &self.alternates)
923 }
924
925 fn env_matches_at(
930 &self,
931 patterns: &[Pattern],
932 env: &Envelope,
933 pos: pattern::Position,
934 mi: usize,
935 ) -> bool {
936 pattern::matches_in(
937 patterns,
938 env,
939 self.scope_at(pos, mi),
940 Some(&|env: &Envelope, m: &pattern::Matcher| {
941 let set = self.body_hits.get(m.raw())?;
942 let uid = remote::uid_of(&env.file.path)?;
943 Some(set.contains(&uid))
944 }),
945 )
946 }
947
948 fn positions(&self) -> Vec<pattern::Position> {
953 let mut seen: HashMap<&str, usize> = HashMap::new();
954 for m in &self.msgs {
955 if let Some(id) = m.env.msg_id.as_deref() {
956 *seen.entry(id).or_default() += 1;
957 }
958 }
959 let mut numbers = vec![0usize; self.msgs.len()];
960 for (n, &mi) in self.visible.iter().enumerate() {
961 if let Some(slot) = numbers.get_mut(mi) {
962 *slot = n + 1;
963 }
964 }
965 let current = self
966 .visible
967 .get(self.sel)
968 .and_then(|&mi| numbers.get(mi).copied())
969 .unwrap_or(0);
970 let last = self.visible.len();
971 (0..self.msgs.len())
972 .map(|i| pattern::Position {
973 number: numbers[i],
974 current,
975 last,
976 duplicate: self.msgs[i]
977 .env
978 .msg_id
979 .as_deref()
980 .is_some_and(|id| seen.get(id).copied().unwrap_or(0) > 1),
981 })
982 .collect()
983 }
984
985 pub fn resolve_body_terms(&mut self, patterns: &[Pattern]) {
990 self.settle();
994 if self.imap.is_none() {
995 return;
996 }
997 for term in pattern::body_terms(patterns) {
998 let simple = !term
999 .chars()
1000 .any(|c| r".*+?[](){}|^$\".contains(c) || !c.is_ascii());
1001 if !simple || self.body_hits.contains_key(&term) {
1002 continue;
1003 }
1004 let Some(imap) = &mut self.imap else { return };
1005 if let Ok(Done::Uids(uids)) = imap.blocking(Job::SearchBody(term.clone())) {
1006 self.body_hits.insert(term, uids.into_iter().collect());
1007 }
1008 }
1009 }
1010
1011 pub fn rescan(&mut self) {
1014 let Ok(files) = maildir::scan(&self.dir) else {
1015 return;
1016 };
1017 let keep = self.selected_path();
1018 let mut old: std::collections::HashMap<PathBuf, Msg> = self
1019 .msgs
1020 .drain(..)
1021 .map(|m| (m.env.file.path.clone(), m))
1022 .collect();
1023 let mut arrived = 0usize;
1024 let mut arrivals: Vec<PathBuf> = Vec::new();
1025 for file in files {
1026 match old.remove(&file.path) {
1027 Some(prev) if prev.pending() => self.msgs.push(prev),
1028 Some(mut prev) => {
1029 prev.env.file = file;
1031 self.msgs.push(prev);
1032 }
1033 None => {
1034 if let Ok(env) = message::envelope(file) {
1035 if env.file.is_new {
1036 arrived += 1;
1037 arrivals.push(env.file.path.clone());
1038 }
1039 self.msgs.push(Msg {
1040 env,
1041 dirty: false,
1042 purge: false,
1043 });
1044 }
1045 }
1046 }
1047 }
1048 self.dir_mtimes = dir_mtimes(&self.dir);
1049 self.resort(keep.clone());
1050 if self.sort == SortKey::Threads
1053 && self.config.index.uncollapse_new.unwrap_or(true)
1054 && !self.collapsed.is_empty()
1055 {
1056 let mut unfolded = false;
1057 for path in &arrivals {
1058 if let Some(mi) = self.msgs.iter().position(|m| &m.env.file.path == path) {
1059 let root = self.thread_root.get(mi).copied().unwrap_or(mi);
1060 unfolded |= self.collapsed.remove(&self.msgs[root].env.file.path);
1061 }
1062 }
1063 if unfolded {
1064 self.rebuild_visible(keep);
1065 }
1066 }
1067 if arrived > 0 {
1068 let title = self.title.clone();
1069 self.notify(Notice::NewMail(format!("new mail in {title} (+{arrived})")));
1070 self.run_new_mail_command(&title, arrived);
1071 }
1072 }
1073
1074 fn run_new_mail_command(&self, mailbox: &str, count: usize) {
1077 let Some(cmd) = &self.config.mail.new_mail_command else {
1078 return;
1079 };
1080 let cmd = cmd
1081 .replace("%f", &format!("'{}'", mailbox.replace('\'', r"'\''")))
1082 .replace("%n", &count.to_string());
1083 let _ = Command::new("sh")
1084 .arg("-c")
1085 .arg(cmd)
1086 .stdout(Stdio::null())
1087 .stderr(Stdio::null())
1088 .spawn();
1089 }
1090
1091 pub fn check_new_mail(&mut self) {
1098 if self.pending.is_some() {
1101 return;
1102 }
1103 let backfilling = self.backfill.as_ref().is_some_and(|b| !b.done());
1104 if !backfilling && self.start(Job::CheckNew, Pending::CheckNew) {
1108 return;
1109 }
1110 self.check_local_mail();
1111 }
1112
1113 fn check_local_mail(&mut self) {
1116 self.maybe_backfill();
1117 if let Some(mbox) = &mut self.mbox {
1118 if let Err(err) = mbox.refresh() {
1120 self.error(format!("mbox: {err:#}"));
1121 }
1122 }
1123 self.check_other_mailboxes();
1124 self.requests.push(Request::MailboxesChanged);
1126 let check_new = self.config.mail.check_new.unwrap_or(true) || self.imap.is_some();
1130 if check_new && dir_mtimes(&self.dir) != self.dir_mtimes {
1131 self.rescan();
1132 }
1133 if self.deferred.is_none() {
1136 self.refresh_unseen();
1137 }
1138 }
1139
1140 pub fn poll_network(&mut self) {
1144 if let Some(line) = self.imap.as_mut().and_then(Imap::take_progress) {
1145 if !self.aborted {
1148 self.note(format!("{line} (Ctrl+G aborts)"));
1149 self.progress_noted = true;
1150 }
1151 }
1152 let Some(done) = self.imap.as_mut().and_then(Imap::collect) else {
1153 return;
1154 };
1155 if mem::take(&mut self.progress_noted) {
1158 self.clear_notice();
1159 }
1160 if let Some(pending) = self.pending.take() {
1161 self.resume(pending, done);
1162 }
1163 if self.pending.is_none()
1166 && let Some(again) = self.deferred.take()
1167 {
1168 self.run_again(again);
1169 }
1170 }
1171
1172 pub fn busy(&self) -> Option<&'static str> {
1174 self.imap.as_ref().and_then(Imap::busy)
1175 }
1176
1177 pub fn abort_network(&mut self) {
1181 let Some(what) = self.busy() else {
1182 return;
1183 };
1184 if let Some(imap) = &self.imap {
1185 imap.abort();
1186 }
1187 self.note(format!("aborted: {what}"));
1188 self.deferred = None;
1190 self.progress_noted = false;
1193 self.aborted = true;
1194 }
1195
1196 fn start(&mut self, job: Job, pending: Pending) -> bool {
1200 let Some(imap) = &mut self.imap else {
1201 return false;
1202 };
1203 let what = job.what();
1204 match imap.start(job) {
1205 Ok(()) => {
1206 self.note(format!("{what}... (Ctrl+G aborts)"));
1210 self.progress_noted = true;
1211 self.pending = Some(pending);
1212 true
1213 }
1214 Err(err) => {
1215 self.error(format!("imap: {err:#}"));
1216 false
1217 }
1218 }
1219 }
1220
1221 fn settle(&mut self) {
1224 while let Some(pending) = self.pending.take() {
1225 let Some(imap) = &mut self.imap else { break };
1226 let done = imap.wait();
1227 if mem::take(&mut self.progress_noted) {
1228 self.clear_notice();
1229 }
1230 self.resume(pending, done);
1231 }
1232 }
1233
1234 fn resume(&mut self, pending: Pending, done: Result<Done>) {
1235 if mem::take(&mut self.aborted) && done.is_err() {
1236 return;
1239 }
1240 match pending {
1241 Pending::CheckNew => {
1242 if let Err(err) = done {
1243 self.error(format!("imap: {err:#}"));
1244 }
1245 self.check_local_mail();
1246 }
1247 Pending::Sync { purge } => match done {
1248 Err(err) => self.error(format!("sync failed: {err:#}")),
1250 Ok(_) => self.finish_sync(purge),
1251 },
1252 Pending::Counts(specs) => {
1253 if let Ok(Done::Counts(counts)) = done {
1254 for (spec, count) in specs.into_iter().zip(counts) {
1255 self.unseen.insert(spec, count);
1256 }
1257 self.requests.push(Request::MailboxesChanged);
1258 }
1259 }
1260 Pending::Again(again) => match done {
1261 Err(err) => self.error(format!("cannot fetch message: {err:#}")),
1262 Ok(_) => self.run_again(again),
1263 },
1264 }
1265 }
1266
1267 fn run_again(&mut self, again: Again) {
1270 match again {
1271 Again::View => self.open_message(),
1272 Again::Copy {
1273 input,
1274 delete,
1275 tagged,
1276 decode,
1277 } => self.copy_message(&input, delete, tagged, decode),
1278 Again::Pipe { command, tagged } => self.pipe_message(&command, tagged),
1279 Again::Print { tagged } => self.print_current(tagged),
1280 Again::Bounce { to, tagged } => self.bounce_current(&to, tagged),
1281 }
1282 }
1283
1284 fn have_bodies(&mut self, paths: &[PathBuf], again: Again) -> bool {
1291 if self.imap.is_none() {
1292 return true;
1293 }
1294 let missing: Vec<PathBuf> = paths
1295 .iter()
1296 .filter(|path| remote::is_partial(path))
1297 .cloned()
1298 .collect();
1299 if missing.is_empty() {
1300 return true;
1301 }
1302 if self.pending.is_some() {
1306 self.deferred = Some(again);
1307 return false;
1308 }
1309 !self.start(Job::FetchBodies(missing), Pending::Again(again))
1310 }
1311
1312 fn target_paths(&self, tagged: bool) -> Vec<PathBuf> {
1315 self.op_targets(tagged)
1316 .into_iter()
1317 .map(|i| self.msgs[i].env.file.path.clone())
1318 .collect()
1319 }
1320
1321 pub fn idle_kick(&self) -> bool {
1324 self.idle.as_ref().is_some_and(|w| w.take_changed())
1325 }
1326
1327 fn check_other_mailboxes(&mut self) {
1330 let mut grew: Vec<String> = Vec::new();
1331 for spec in self.config.mail.mailboxes.clone() {
1332 if spec.starts_with("imap:") || spec == self.title {
1333 continue; }
1335 let dir = expand_tilde(&spec);
1336 if dir == self.dir || !dir.join("new").is_dir() {
1337 continue;
1338 }
1339 let count = maildir::new_count(&dir);
1340 let prev = self.mailbox_new.insert(spec.clone(), count);
1341 if let Some(p) = prev.filter(|&p| count > p) {
1342 self.run_new_mail_command(&spec, count - p);
1343 grew.push(spec);
1344 } else if !self.config.mail.mail_check_recent.unwrap_or(true) {
1345 if count == 0 {
1349 self.mailbox_announced.remove(&spec);
1350 } else if self.mailbox_announced.insert(spec.clone()) {
1351 grew.push(spec);
1352 }
1353 }
1354 }
1355 if !grew.is_empty() && self.notice().is_none() {
1357 self.notify(Notice::NewMail(format!("new mail in {}", grew.join(", "))));
1358 }
1359 }
1360
1361 pub fn maybe_backfill(&mut self) {
1364 if self.backfill.as_ref().is_some_and(|b| !b.done()) {
1365 return;
1366 }
1367 self.backfill = None;
1368 let Some(imap) = &mut self.imap else {
1369 return;
1370 };
1371 if imap.facts.pending_backfill.is_empty() {
1372 return;
1373 }
1374 let uids = imap.take_backfill();
1375 let facts = imap.facts.clone();
1376 let Ok(password) = account_password(&facts.account) else {
1377 return;
1378 };
1379 let count = uids.len();
1380 self.backfill = Some(remote::backfill(
1381 &facts.account,
1382 &facts.mailbox,
1383 &password,
1384 facts.cache,
1385 uids,
1386 ));
1387 self.note(format!(
1388 "loading {count} older message(s) in the background"
1389 ));
1390 }
1391
1392 pub fn error_history(&self) -> Vec<String> {
1396 self.error_history.iter().cloned().collect()
1397 }
1398
1399 pub fn next_unread_mailbox(&self) -> Option<String> {
1404 let boxes = &self.config.mail.mailboxes;
1405 let start = boxes
1406 .iter()
1407 .position(|spec| *spec == self.title || expand_tilde(spec) == self.dir)
1408 .map(|i| i + 1)
1409 .unwrap_or(0);
1410 (0..boxes.len())
1411 .map(|k| &boxes[(start + k) % boxes.len()])
1412 .filter(|spec| **spec != self.title && expand_tilde(spec) != self.dir)
1413 .find(|spec| {
1414 if spec.starts_with("imap:") {
1415 self.unseen.get(*spec).copied().unwrap_or(0) > 0
1416 } else {
1417 maildir::new_count(&expand_tilde(spec)) > 0
1418 }
1419 })
1420 .cloned()
1421 }
1422
1423 pub fn delete_rules(&self) -> DeleteRules {
1426 DeleteRules {
1427 flag_safe: self.config.mail.flag_safe,
1428 untag: self.config.mail.delete_untag.unwrap_or(true),
1429 }
1430 }
1431
1432 pub fn each_tagged(&mut self, what: &str, f: impl Fn(&mut Msg)) {
1433 let tagged: Vec<usize> = (0..self.msgs.len())
1434 .filter(|&i| self.msgs[i].env.tagged)
1435 .collect();
1436 self.push_undo(what, &tagged);
1437 for &i in &tagged {
1438 let m = &mut self.msgs[i];
1439 f(m);
1440 m.dirty = true;
1441 }
1442 self.note(format!("applied to {} tagged message(s)", tagged.len()));
1443 }
1444
1445 pub fn op_targets(&self, tagged: bool) -> Vec<usize> {
1453 if tagged {
1454 return (0..self.msgs.len())
1455 .filter(|&i| self.msgs[i].env.tagged)
1456 .collect();
1457 }
1458 self.visible.get(self.sel).copied().into_iter().collect()
1459 }
1460
1461 pub fn push_undo(&mut self, what: &str, indices: &[usize]) {
1464 if indices.is_empty() {
1465 return;
1466 }
1467 let marks = indices.iter().map(|&i| self.mark(i)).collect();
1468 self.push_undo_step(UndoStep {
1469 what: what.to_string(),
1470 marks,
1471 sel: self.selected_path(),
1472 created: Vec::new(),
1473 note: None,
1474 rewritten: Vec::new(),
1475 });
1476 }
1477
1478 pub fn push_undo_step(&mut self, step: UndoStep) {
1479 self.undo.push(step);
1480 while self.undo.len() > UNDO_MAX_STEPS
1482 || (self.undo.len() > 1
1483 && self.undo.iter().map(|s| s.marks.len()).sum::<usize>() > UNDO_MAX_MARKS)
1484 {
1485 self.undo.remove(0);
1486 }
1487 }
1488
1489 pub fn mark(&self, i: usize) -> MsgMark {
1490 let m = &self.msgs[i];
1491 MsgMark {
1492 path: m.env.file.path.clone(),
1493 flags: m.env.file.flags,
1494 is_new: m.env.file.is_new,
1495 tagged: m.env.tagged,
1496 dirty: m.dirty,
1497 }
1498 }
1499
1500 pub fn undo_last(&mut self) {
1506 let Some(step) = self.undo.pop() else {
1507 self.note("nothing to undo");
1508 return;
1509 };
1510 let mut restored = 0usize;
1511 let mut rewrite_failed = Vec::new();
1512 let mut resort = false;
1513 for (path, bytes) in &step.rewritten {
1514 let at = self.msgs.iter().position(|m| &m.env.file.path == path);
1515 let put_back = std::fs::write(path, bytes)
1516 .map_err(|err| anyhow::anyhow!(err))
1517 .and_then(|()| match at {
1518 Some(i) => self.reread(i, bytes.len() as u64),
1519 None => Ok(()),
1520 });
1521 match put_back {
1522 Ok(()) => resort = true,
1523 Err(err) => rewrite_failed.push(format!("{}: {err}", path.display())),
1524 }
1525 }
1526 if resort {
1527 let keep = step.sel.clone();
1528 self.resort(keep);
1529 }
1530 let by_path: HashMap<&Path, usize> = self
1531 .msgs
1532 .iter()
1533 .enumerate()
1534 .map(|(i, m)| (m.env.file.path.as_path(), i))
1535 .collect();
1536 let mut wanted: Vec<(usize, MsgMark)> = Vec::new();
1537 for mark in &step.marks {
1538 if let Some(&i) = by_path.get(mark.path.as_path()) {
1539 wanted.push((i, mark.clone()));
1540 }
1541 }
1542 for (i, mark) in wanted {
1543 let m = &mut self.msgs[i];
1544 m.env.file.flags = mark.flags;
1545 m.env.file.is_new = mark.is_new;
1546 m.env.tagged = mark.tagged;
1547 m.dirty = mark.dirty;
1548 restored += 1;
1549 }
1550 let mut failed = rewrite_failed;
1551 for path in &step.created {
1552 if let Err(err) = std::fs::remove_file(path) {
1553 failed.push(format!("{}: {err}", path.display()));
1554 }
1555 }
1556 if let Some(vi) = step.sel.and_then(|p| {
1557 self.visible
1558 .iter()
1559 .position(|&i| self.msgs[i].env.file.path == p)
1560 }) {
1561 self.sel = vi;
1562 }
1563 let mut note = format!("undone: {} ({restored} message(s))", step.what);
1564 if let Some(extra) = &step.note {
1565 note += &format!("; {extra}");
1566 }
1567 if !failed.is_empty() {
1568 self.error(format!("{note}; could not remove {}", failed.join("; ")));
1569 } else {
1570 self.note(note);
1571 }
1572 }
1573
1574 pub fn message_bytes(&mut self, i: usize) -> Option<Vec<u8>> {
1577 self.settle();
1581 let path = self.msgs.get(i)?.env.file.path.clone();
1582 if remote::is_partial(&path)
1583 && let Some(imap) = &mut self.imap
1584 && let Err(err) = imap.blocking(Job::FetchBodies(vec![path.clone()]))
1585 {
1586 self.error(format!("cannot fetch message: {err:#}"));
1587 return None;
1588 }
1589 match std::fs::read(&path) {
1590 Ok(bytes) => Some(bytes),
1591 Err(err) => {
1592 self.error(format!("cannot read message: {err}"));
1593 None
1594 }
1595 }
1596 }
1597
1598 pub fn full_message_bytes(&mut self) -> Option<Vec<u8>> {
1599 let &i = self.visible.get(self.sel)?;
1600 self.message_bytes(i)
1601 }
1602
1603 pub fn op_bytes(&mut self, tagged: bool) -> Option<Vec<u8>> {
1605 let mut out = Vec::new();
1606 for i in self.op_targets(tagged) {
1607 let bytes = self.message_bytes(i)?;
1608 out.extend_from_slice(&bytes);
1609 if !bytes.ends_with(b"\n") {
1610 out.push(b'\n');
1611 }
1612 }
1613 Some(out)
1614 }
1615
1616 pub fn rebuild_visible(&mut self, keep: Option<PathBuf>) {
1619 let mut visible = Vec::with_capacity(self.msgs.len());
1620 let positions = self.positions();
1621 for (i, pos) in positions.iter().enumerate() {
1622 let limit_ok = match &self.limit {
1623 Some((_, patterns)) => self.env_matches_at(patterns, &self.msgs[i].env, *pos, i),
1624 None => true,
1625 };
1626 if !limit_ok {
1627 continue;
1628 }
1629 if self.limit.is_none()
1630 && self.sort == SortKey::Threads
1631 && self.thread_depth.get(i).copied().unwrap_or(0) > 0
1632 {
1633 let root = self.thread_root.get(i).copied().unwrap_or(i);
1634 if self.collapsed.contains(&self.msgs[root].env.file.path) {
1635 continue;
1636 }
1637 }
1638 visible.push(i);
1639 }
1640 self.visible = visible;
1641 self.sel = keep
1642 .and_then(|p| {
1643 self.visible
1644 .iter()
1645 .position(|&i| self.msgs[i].env.file.path == p)
1646 })
1647 .unwrap_or_else(|| self.visible.len().saturating_sub(1));
1648 }
1649
1650 pub fn apply_sort(&mut self) {
1651 let keep = self.selected_path();
1652 self.resort(keep);
1653 }
1654
1655 fn resort(&mut self, keep: Option<PathBuf>) {
1656 if self.sort == SortKey::Threads {
1657 let order = self
1660 .config
1661 .index
1662 .sort_aux
1663 .as_deref()
1664 .map(thread::ThreadOrder::parse)
1665 .unwrap_or_default();
1666 let items = {
1667 let envs: Vec<&Envelope> = self.msgs.iter().map(|m| &m.env).collect();
1668 thread::thread_by(&envs, order)
1669 };
1670 let mut old: Vec<Option<Msg>> = self.msgs.drain(..).map(Some).collect();
1671 let mut new_pos = vec![0usize; old.len()];
1672 for (pos, item) in items.iter().enumerate() {
1673 new_pos[item.index] = pos;
1674 }
1675 self.msgs = items
1676 .iter()
1677 .map(|item| {
1678 old[item.index]
1679 .take()
1680 .expect("thread order is a permutation")
1681 })
1682 .collect();
1683 self.thread_depth = items.iter().map(|item| item.depth).collect();
1684 self.thread_root = items.iter().map(|item| new_pos[item.root]).collect();
1685 self.sort_rev = false;
1686 self.index_threads();
1687 } else {
1688 let (sort, rev) = (self.sort, self.sort_rev);
1689 self.msgs.sort_by(|a, b| {
1690 let ord = match sort {
1691 SortKey::Date => a.env.date.cmp(&b.env.date),
1692 SortKey::From => a.env.from.to_lowercase().cmp(&b.env.from.to_lowercase()),
1693 SortKey::Subject => {
1694 subject_key(&a.env.subject).cmp(&subject_key(&b.env.subject))
1695 }
1696 SortKey::Size => a.env.file.size.cmp(&b.env.file.size),
1697 SortKey::Label => {
1698 let key = |e: &Envelope| {
1700 let l = e.label.clone().unwrap_or_default().to_lowercase();
1701 (l.is_empty(), l)
1702 };
1703 key(&a.env).cmp(&key(&b.env))
1704 }
1705 SortKey::To => {
1706 let first_to =
1707 |e: &Envelope| e.to.first().cloned().unwrap_or_default().to_lowercase();
1708 first_to(&a.env).cmp(&first_to(&b.env))
1709 }
1710 SortKey::Unsorted => a
1711 .env
1712 .file
1713 .path
1714 .file_name()
1715 .cmp(&b.env.file.path.file_name()),
1716 SortKey::Threads => unreachable!(),
1717 };
1718 if rev { ord.reverse() } else { ord }
1719 });
1720 self.thread_depth = vec![0; self.msgs.len()];
1721 self.thread_root = (0..self.msgs.len()).collect();
1722 self.index_threads();
1723 }
1724 self.rebuild_visible(keep);
1725 }
1726
1727 fn index_threads(&mut self) {
1730 let n = self.msgs.len();
1731 let mut parent = vec![None; n];
1732 let mut children = vec![Vec::new(); n];
1733 let mut members: Vec<Vec<usize>> = vec![Vec::new(); n];
1734 let mut last_at_depth: Vec<usize> = Vec::new();
1735 #[allow(clippy::needless_range_loop)]
1736 for i in 0..n {
1737 let depth = self.thread_depth.get(i).copied().unwrap_or(0);
1738 last_at_depth.truncate(depth);
1739 if let Some(&p) = last_at_depth.last() {
1740 parent[i] = Some(p);
1741 children[p].push(i);
1742 }
1743 last_at_depth.push(i);
1744 let root = self.thread_root.get(i).copied().unwrap_or(i);
1745 members[root].push(i);
1746 }
1747 self.thread_parent = parent;
1748 self.thread_children = children;
1749 self.thread_members = members;
1750 }
1751
1752 pub fn toggle_collapse(&mut self, all: bool) {
1753 if self.sort != SortKey::Threads {
1754 self.error("folding needs thread sort (o t)");
1755 return;
1756 }
1757 let mut keep;
1758 if all {
1759 keep = self.selected_path();
1760 if self.collapsed.is_empty() {
1761 let fold_unread = self.config.index.collapse_unread.unwrap_or(true);
1764 self.collapsed = (0..self.msgs.len())
1765 .filter(|&i| self.thread_depth.get(i) == Some(&0))
1766 .filter(|&i| fold_unread || !self.thread_has_unread(i))
1767 .map(|i| self.msgs[i].env.file.path.clone())
1768 .collect();
1769 } else {
1770 self.collapsed.clear();
1771 }
1772 } else {
1773 let Some(&mi) = self.visible.get(self.sel) else {
1774 return;
1775 };
1776 let root = self.thread_root.get(mi).copied().unwrap_or(mi);
1777 let path = self.msgs[root].env.file.path.clone();
1778 let unfolding = self.collapsed.remove(&path);
1779 if !unfolding {
1780 self.collapsed.insert(path.clone());
1781 }
1782 keep = Some(path);
1783 if unfolding && self.config.index.uncollapse_jump {
1785 keep = self.first_unread_in_thread(root).or(keep);
1786 }
1787 }
1788 self.rebuild_visible(keep);
1789 }
1790
1791 pub fn step_message(&self, forward: bool, skip_deleted: bool) -> Option<usize> {
1794 let mut pos = self.sel;
1795 loop {
1796 pos = if forward {
1797 pos + 1
1798 } else {
1799 pos.checked_sub(1)?
1800 };
1801 let &i = self.visible.get(pos)?;
1802 if !skip_deleted || !self.msgs[i].env.file.flags.deleted {
1803 return Some(pos);
1804 }
1805 }
1806 }
1807
1808 fn thread_targets(&mut self, sub: bool) -> Option<Vec<usize>> {
1816 if self.sort != SortKey::Threads {
1817 self.error("thread operations need thread sort (o t)");
1818 return None;
1819 }
1820 let &mi = self.visible.get(self.sel)?;
1821 if !sub {
1822 let root = self.thread_root.get(mi).copied().unwrap_or(mi);
1823 return Some(
1824 (0..self.msgs.len())
1825 .filter(|&i| self.thread_root.get(i).copied().unwrap_or(i) == root)
1826 .collect(),
1827 );
1828 }
1829 let depth = self.thread_depth.get(mi).copied().unwrap_or(0);
1832 let mut out = vec![mi];
1833 for i in (mi + 1)..self.msgs.len() {
1834 if self.thread_depth.get(i).copied().unwrap_or(0) <= depth {
1835 break;
1836 }
1837 out.push(i);
1838 }
1839 Some(out)
1840 }
1841
1842 pub fn thread_mark(&mut self, sub: bool, op: ThreadOp) {
1846 if op != ThreadOp::Tag && self.deny_readonly() {
1847 return;
1848 }
1849 let Some(&mi) = self.visible.get(self.sel) else {
1850 return;
1851 };
1852 let Some(targets) = self.thread_targets(sub) else {
1853 return;
1854 };
1855 let scope = if sub { "subthread" } else { "thread" };
1856 let (what, verb) = match op {
1857 ThreadOp::Delete => (format!("delete {scope}"), "deleted"),
1858 ThreadOp::Undelete => (format!("undelete {scope}"), "undeleted"),
1859 ThreadOp::Read => (format!("read {scope}"), "marked read"),
1860 ThreadOp::Tag if self.msgs[mi].env.tagged => (format!("untag {scope}"), "untagged"),
1863 ThreadOp::Tag => (format!("tag {scope}"), "tagged"),
1864 };
1865 self.push_undo(&what, &targets);
1866 let want_tag = !self.msgs[mi].env.tagged;
1867 let rules = self.delete_rules();
1868 for &i in &targets {
1869 match op {
1870 ThreadOp::Delete => {
1871 if rules.mark(&mut self.msgs[i]) {
1872 self.msgs[i].dirty = true;
1873 }
1874 }
1875 ThreadOp::Undelete => {
1876 self.msgs[i].env.file.flags.deleted = false;
1877 self.msgs[i].dirty = true;
1878 }
1879 ThreadOp::Tag => self.msgs[i].env.tagged = want_tag,
1880 ThreadOp::Read => {
1881 let m = &mut self.msgs[i];
1882 if !m.env.file.flags.seen || m.env.file.is_new {
1883 m.env.file.flags.seen = true;
1884 m.env.file.is_new = false;
1885 m.dirty = true;
1886 }
1887 }
1888 }
1889 }
1890 self.note(format!("{} {verb}", targets.len()));
1891 if op == ThreadOp::Delete
1894 && let Some(pos) = self.step_message(true, true)
1895 {
1896 self.sel = pos;
1897 }
1898 }
1899
1900 pub fn jump_parent(&mut self, root: bool) {
1904 if self.sort != SortKey::Threads {
1905 self.error("thread operations need thread sort (o t)");
1906 return;
1907 }
1908 let Some(&mi) = self.visible.get(self.sel) else {
1909 return;
1910 };
1911 let target = if root {
1912 self.thread_root.get(mi).copied().filter(|&r| r != mi)
1913 } else {
1914 self.thread_parent.get(mi).copied().flatten()
1915 };
1916 let Some(target) = target else {
1917 self.error(if root {
1918 "already the thread root"
1919 } else {
1920 "no parent message"
1921 });
1922 return;
1923 };
1924 let tr = self.thread_root.get(target).copied().unwrap_or(target);
1925 let at = self
1926 .visible
1927 .iter()
1928 .position(|&i| i == target)
1929 .or_else(|| self.visible.iter().position(|&i| i == tr));
1930 match at {
1931 Some(vi) => self.select(vi),
1932 None => self.error("the parent is not in the current limit"),
1933 }
1934 }
1935
1936 fn can_rewrite(&mut self) -> bool {
1941 if self.deny_readonly() {
1942 return false;
1943 }
1944 if self.sort != SortKey::Threads {
1945 self.error("thread operations need thread sort (o t)");
1946 return false;
1947 }
1948 if self.imap.is_some() || self.mbox.is_some() {
1949 self.error("rewriting a message's threading is for local maildirs only");
1950 return false;
1951 }
1952 true
1953 }
1954
1955 fn rewrite_thread(
1959 &mut self,
1960 i: usize,
1961 in_reply_to: Option<&str>,
1962 references: &[String],
1963 ) -> Result<Vec<u8>> {
1964 let path = self.msgs[i].env.file.path.clone();
1965 let old = std::fs::read(&path).with_context(|| format!("reading {}", path.display()))?;
1966 let new = message::with_thread_headers(&old, in_reply_to, references);
1967 std::fs::write(&path, &new).with_context(|| format!("writing {}", path.display()))?;
1968 self.reread(i, new.len() as u64)?;
1969 Ok(old)
1970 }
1971
1972 fn reread(&mut self, i: usize, size: u64) -> Result<()> {
1975 let mut file = self.msgs[i].env.file.clone();
1976 file.size = size;
1977 let tagged = self.msgs[i].env.tagged;
1978 let mut env = message::envelope(file)?;
1979 env.tagged = tagged;
1980 self.msgs[i].env = env;
1981 Ok(())
1982 }
1983
1984 pub fn break_thread(&mut self) {
1989 let Some(&mi) = self.visible.get(self.sel) else {
1990 return;
1991 };
1992 if !self.can_rewrite() {
1993 return;
1994 }
1995 if self.msgs[mi].env.references.is_empty() {
1996 self.note("already a thread of its own");
1997 return;
1998 }
1999 let path = self.msgs[mi].env.file.path.clone();
2000 let mut step = UndoStep {
2001 what: "break thread".into(),
2002 marks: vec![self.mark(mi)],
2003 sel: Some(path.clone()),
2004 created: Vec::new(),
2005 note: None,
2006 rewritten: Vec::new(),
2007 };
2008 match self.rewrite_thread(mi, None, &[]) {
2009 Ok(old) => {
2010 step.rewritten.push((path.clone(), old));
2011 self.push_undo_step(step);
2012 self.resort(Some(path));
2013 self.note("thread broken");
2014 }
2015 Err(err) => self.error(format!("break thread: {err:#}")),
2016 }
2017 }
2018
2019 pub fn link_threads(&mut self) {
2024 let Some(&mi) = self.visible.get(self.sel) else {
2025 return;
2026 };
2027 if !self.can_rewrite() {
2028 return;
2029 }
2030 let Some(parent_id) = self.msgs[mi].env.msg_id.clone() else {
2031 self.error("no Message-ID to link to");
2032 return;
2033 };
2034 let kids: Vec<usize> = (0..self.msgs.len())
2035 .filter(|&i| i != mi && self.msgs[i].env.tagged)
2036 .collect();
2037 if kids.is_empty() {
2038 self.error("first tag the message(s) to link");
2039 return;
2040 }
2041 let keep = self.msgs[mi].env.file.path.clone();
2042 let mut step = UndoStep {
2043 what: "link threads".into(),
2044 marks: kids.iter().map(|&i| self.mark(i)).collect(),
2045 sel: Some(keep.clone()),
2046 created: Vec::new(),
2047 note: None,
2048 rewritten: Vec::new(),
2049 };
2050 let mut linked = 0usize;
2051 for &i in &kids {
2052 let path = self.msgs[i].env.file.path.clone();
2053 match self.rewrite_thread(i, Some(&parent_id), &[]) {
2054 Ok(old) => {
2055 step.rewritten.push((path, old));
2056 self.msgs[i].env.tagged = false;
2057 linked += 1;
2058 }
2059 Err(err) => self.error(format!("link threads: {err:#}")),
2060 }
2061 }
2062 if linked > 0 {
2063 self.push_undo_step(step);
2064 self.resort(Some(keep));
2065 self.note(format!("{linked} linked"));
2066 }
2067 }
2068
2069 pub fn edit_label(&mut self, value: &str, tagged: bool) {
2074 if !self.can_rewrite_here() {
2075 return;
2076 }
2077 let targets = self.op_targets(tagged);
2078 if targets.is_empty() {
2079 return;
2080 }
2081 let value = value.trim();
2082 let new = (!value.is_empty()).then(|| value.to_string());
2083 let keep = self.selected_path();
2084 let mut step = UndoStep {
2085 what: "edit label".into(),
2086 marks: targets.iter().map(|&i| self.mark(i)).collect(),
2087 sel: keep.clone(),
2088 created: Vec::new(),
2089 note: None,
2090 rewritten: Vec::new(),
2091 };
2092 let mut done = 0usize;
2093 for &i in &targets {
2094 let path = self.msgs[i].env.file.path.clone();
2095 let old = match std::fs::read(&path) {
2096 Ok(b) => b,
2097 Err(err) => {
2098 self.error(format!("edit label: {err}"));
2099 continue;
2100 }
2101 };
2102 let bytes = message::with_header(&old, "X-Label", new.as_deref());
2103 if let Err(err) = std::fs::write(&path, &bytes) {
2104 self.error(format!("edit label: {err}"));
2105 continue;
2106 }
2107 step.rewritten.push((path, old));
2108 let _ = self.reread(i, bytes.len() as u64);
2109 done += 1;
2110 }
2111 if done > 0 {
2112 self.push_undo_step(step);
2113 self.resort(keep);
2114 self.note(match new {
2115 Some(_) => format!("labelled {done} message(s)"),
2116 None => format!("label cleared on {done} message(s)"),
2117 });
2118 }
2119 }
2120
2121 fn can_rewrite_here(&mut self) -> bool {
2124 if self.deny_readonly() {
2125 return false;
2126 }
2127 if self.imap.is_some() || self.mbox.is_some() {
2128 self.error("editing a label is for local maildirs only");
2129 return false;
2130 }
2131 true
2132 }
2133
2134 pub fn subject_hidden(&self, mi: usize) -> bool {
2139 if !self.config.index.hide_thread_subject.unwrap_or(false) {
2140 return false;
2141 }
2142 let Some(parent) = self.thread_parent.get(mi).copied().flatten() else {
2143 return false;
2144 };
2145 match (self.msgs.get(mi), self.msgs.get(parent)) {
2146 (Some(m), Some(p)) => subject_key(&m.env.subject) == subject_key(&p.env.subject),
2147 _ => false,
2148 }
2149 }
2150
2151 pub fn toggle_write(&mut self) {
2155 if self.read_only_session {
2156 self.error("mailbox is read-only for the whole session (-R)");
2157 return;
2158 }
2159 self.read_only = !self.read_only;
2160 if self.read_only {
2161 self.note("mailbox marked read-only");
2162 } else {
2163 self.note("mailbox marked writable");
2164 }
2165 }
2166
2167 pub fn show_limit(&mut self) {
2170 match &self.limit {
2171 Some((raw, _)) => self.note(format!("limit: {raw}")),
2172 None => self.note("no limit pattern (all messages shown)"),
2173 }
2174 }
2175
2176 pub fn reply_subject(&self, orig: &str) -> String {
2178 compose::reply_subject(orig, &self.reply_re)
2179 }
2180
2181 pub fn jump_thread(&mut self, forward: bool) {
2184 if self.sort != SortKey::Threads {
2185 self.error("thread operations need thread sort (o t)");
2186 return;
2187 }
2188 for (vi, wrapped) in wrap_order(self.visible.len(), self.sel, forward) {
2189 let mi = self.visible[vi];
2190 if self.thread_depth.get(mi).copied().unwrap_or(0) == 0 {
2191 if wrapped {
2192 self.note("wrapped around");
2193 }
2194 self.select(vi);
2195 return;
2196 }
2197 }
2198 self.error("no other thread");
2199 }
2200
2201 pub fn jump_new(&mut self, forward: bool) {
2202 let n = self.visible.len();
2203 if n == 0 {
2204 return;
2205 }
2206 for (vi, wrapped) in wrap_order(n, self.sel, forward) {
2207 let m = &self.msgs[self.visible[vi]];
2208 if m.env.file.is_new || !m.env.file.flags.seen {
2209 if wrapped {
2210 self.note("search wrapped");
2211 }
2212 self.select(vi);
2213 return;
2214 }
2215 }
2216 self.error("no new or unread messages");
2217 }
2218
2219 pub fn compile_search(&self, input: &str) -> Result<Vec<Pattern>, String> {
2227 pattern::parse(&self.expand_simple(input))
2228 }
2229
2230 fn expand_simple(&self, input: &str) -> String {
2231 let word = input.trim();
2232 if word.contains('~') || word.split_whitespace().count() != 1 {
2233 return input.to_string();
2234 }
2235 let template = self
2236 .config
2237 .mail
2238 .simple_search
2239 .as_deref()
2240 .unwrap_or("~f %s | ~s %s");
2241 template.replace("%s", word)
2242 }
2243
2244 pub fn apply_pattern(&mut self, input: &str, verb: &'static str, f: impl Fn(&mut Msg)) {
2245 if input.is_empty() {
2246 return;
2247 }
2248 let patterns = match self.compile_search(input) {
2249 Ok(p) => p,
2250 Err(err) => {
2251 self.error(format!("bad pattern: {err}"));
2252 return;
2253 }
2254 };
2255 self.resolve_body_terms(&patterns);
2256 let positions = self.positions();
2257 let mut hits = Vec::new();
2258 for (i, pos) in positions.iter().enumerate() {
2259 let in_limit = match &self.limit {
2260 Some((_, l)) => self.env_matches_at(l, &self.msgs[i].env, *pos, i),
2261 None => true,
2262 };
2263 if in_limit && self.env_matches_at(&patterns, &self.msgs[i].env, *pos, i) {
2264 hits.push(i);
2265 }
2266 }
2267 self.push_undo(&format!("{verb} by pattern"), &hits);
2268 for &i in &hits {
2269 f(&mut self.msgs[i]);
2270 }
2271 self.note(format!("{} {verb}", hits.len()));
2272 }
2273
2274 pub fn search_next(&mut self) {
2275 let Some(patterns) = self.last_search.clone() else {
2276 self.error("no search pattern (use /)");
2277 return;
2278 };
2279 if self.visible.is_empty() {
2280 return;
2281 }
2282 let wrap = self.config.mail.wrap_search.unwrap_or(true);
2285 let positions = self.positions();
2286 for (vi, wrapped) in wrap_order(self.visible.len(), self.sel, !self.search_rev) {
2287 if wrapped && !wrap {
2288 break;
2289 }
2290 let mi = self.visible[vi];
2291 if self.env_matches_at(&patterns, &self.msgs[mi].env, positions[mi], mi) {
2292 if wrapped {
2293 self.note("search wrapped");
2294 }
2295 self.sel = vi;
2296 return;
2297 }
2298 }
2299 self.error("not found");
2300 }
2301
2302 fn trash_deleted(&mut self, trash: &str) -> Result<()> {
2306 self.settle();
2310 let deleted: Vec<PathBuf> = self
2312 .msgs
2313 .iter()
2314 .filter(|m| m.env.file.flags.deleted && !m.purge)
2315 .map(|m| m.env.file.path.clone())
2316 .collect();
2317 match (remote::parse_spec(trash), &mut self.imap) {
2318 (Some((account, folder)), Some(imap)) if imap.facts.account.name == account => imap
2319 .blocking(Job::CopyToFolder {
2320 paths: deleted,
2321 mailbox: folder.to_string(),
2322 })
2323 .map(|_| ()),
2324 (Some(_), _) => anyhow::bail!("trash must be a folder of the open account"),
2325 (None, Some(_)) => {
2326 anyhow::bail!("an IMAP mailbox needs an imap:account/folder trash")
2327 }
2328 (None, None) => {
2329 let dir = expand_tilde(trash);
2330 maildir::create(&dir)?;
2331 for m in self
2332 .msgs
2333 .iter()
2334 .filter(|m| m.env.file.flags.deleted && !m.purge)
2335 {
2336 let bytes = std::fs::read(&m.env.file.path)?;
2337 let mut flags = m.env.file.flags;
2338 flags.deleted = false;
2339 maildir::deliver(&dir, &bytes, flags)?;
2340 }
2341 Ok(())
2342 }
2343 }
2344 }
2345
2346 pub fn sync(&mut self, purge: bool) {
2349 if self.deny_readonly() {
2350 return;
2351 }
2352 if purge
2355 && self.deleted_count() > 0
2356 && let Some(trash) = self.config.mail.trash.clone()
2357 && trash != self.title
2358 && expand_tilde(&trash) != self.dir
2359 && let Err(err) = self.trash_deleted(&trash)
2360 {
2361 self.error(format!("trash failed: {err:#}; nothing purged"));
2362 return;
2363 }
2364 if self.imap.is_some() {
2365 let mut deletes: Vec<PathBuf> = Vec::new();
2366 let mut flags: Vec<(PathBuf, maildir::Flags)> = Vec::new();
2367 for m in &self.msgs {
2368 if m.env.file.flags.deleted {
2369 if purge {
2370 deletes.push(m.env.file.path.clone());
2371 }
2372 } else if m.dirty {
2373 flags.push((m.env.file.path.clone(), m.env.file.flags));
2374 }
2375 }
2376 if self.start(Job::Sync { flags, deletes }, Pending::Sync { purge }) {
2380 return;
2381 }
2382 }
2383 self.finish_sync(purge);
2384 }
2385
2386 fn finish_sync(&mut self, purge: bool) {
2389 if let Some(mbox) = &mut self.mbox {
2390 let mut state: HashMap<String, Option<(maildir::Flags, bool)>> = HashMap::new();
2393 for m in &self.msgs {
2394 let Some(id) = mbox::id_of(&m.env.file.path) else {
2395 continue;
2396 };
2397 if m.env.file.flags.deleted && purge {
2398 state.insert(id, None);
2399 } else if m.pending() {
2400 let is_new = m.env.file.is_new && !m.dirty;
2401 state.insert(id, Some((m.env.file.flags, is_new)));
2402 }
2403 }
2404 if let Err(err) = mbox.write_back(&state) {
2405 self.error(format!("sync failed: {err:#}"));
2407 return;
2408 }
2409 }
2410 let keep = self.selected_path();
2411 let mut removed = 0usize;
2412 let mut saved = 0usize;
2413 let mut errors: Vec<String> = Vec::new();
2414 let trash = self.config.mail.maildir_trash && self.imap.is_none() && self.mbox.is_none();
2418 self.msgs.retain_mut(|m| {
2419 if m.env.file.flags.deleted {
2420 if !purge {
2421 return true; }
2423 if trash {
2424 if m.dirty {
2425 match maildir::store_flags(&m.env.file) {
2426 Ok(path) => {
2427 m.env.file.path = path;
2428 m.env.file.is_new = false;
2429 m.dirty = false;
2430 saved += 1;
2431 }
2432 Err(err) => errors.push(err.to_string()),
2433 }
2434 }
2435 return true;
2436 }
2437 match maildir::remove(&m.env.file) {
2438 Ok(()) => {
2439 removed += 1;
2440 false
2441 }
2442 Err(err) => {
2443 errors.push(err.to_string());
2444 true
2445 }
2446 }
2447 } else {
2448 if m.dirty {
2449 match maildir::store_flags(&m.env.file) {
2450 Ok(path) => {
2451 m.env.file.path = path;
2452 m.env.file.is_new = false;
2453 m.dirty = false;
2454 saved += 1;
2455 }
2456 Err(err) => errors.push(err.to_string()),
2457 }
2458 }
2459 true
2460 }
2461 });
2462 self.dir_mtimes = dir_mtimes(&self.dir);
2463 self.undo.clear();
2466 self.resort(keep);
2467 match errors.is_empty() {
2468 true => self.notify(Notice::Synced {
2469 deleted: removed,
2470 updated: saved,
2471 }),
2472 false => self.note(format!("sync errors: {}", errors.join("; "))),
2473 }
2474 }
2475
2476 pub fn create_folder(&mut self, spec: &str) -> Result<String, String> {
2487 let id = self.manage(
2488 spec,
2489 |_account, folder| Manage::Create(folder),
2490 |path| {
2491 maildir::create(&path).map_err(|e| format!("{e:#}"))?;
2492 Ok(path.display().to_string())
2493 },
2494 )?;
2495 Ok(format!("created {id}"))
2496 }
2497
2498 pub fn delete_folder(&mut self, spec: &str) -> Result<String, String> {
2499 let id = self.manage(
2500 spec,
2501 |_account, folder| Manage::Delete(folder),
2502 |path| {
2503 std::fs::remove_dir_all(&path).map_err(|e| format!("{e}"))?;
2506 Ok(path.display().to_string())
2507 },
2508 )?;
2509 Ok(format!("deleted {id}"))
2510 }
2511
2512 pub fn set_subscribed(&mut self, spec: &str, on: bool) -> Result<String, String> {
2513 let verb = if on {
2514 "subscribed to"
2515 } else {
2516 "unsubscribed from"
2517 };
2518 self.manage(
2519 spec,
2520 move |_account, folder| Manage::Subscribe(folder, on),
2521 |_path| Err("subscription is an IMAP notion".into()),
2522 )
2523 .map(|shown| format!("{verb} {shown}"))
2524 }
2525
2526 pub fn rename_folder(&mut self, spec: &str, new: &str) -> Result<String, String> {
2529 if new.trim().is_empty() {
2530 return Err("no new name given".into());
2531 }
2532 match remote::parse_spec(spec) {
2533 Some((account, from)) => {
2534 let to = new.trim().to_string();
2535 self.on_account(account, Manage::Rename(from.to_string(), to.clone()))?;
2536 Ok(format!("renamed to {to}"))
2537 }
2538 None => {
2539 let from = expand_tilde(spec);
2540 let to = expand_tilde(new);
2541 std::fs::rename(&from, &to).map_err(|e| format!("{e}"))?;
2542 Ok(format!("renamed to {}", to.display()))
2543 }
2544 }
2545 }
2546
2547 fn manage(
2551 &mut self,
2552 spec: &str,
2553 make: impl FnOnce(&str, String) -> Manage,
2554 local: impl FnOnce(std::path::PathBuf) -> Result<String, String>,
2555 ) -> Result<String, String> {
2556 match remote::parse_spec(spec) {
2557 Some((account, folder)) => {
2558 let action = make(account, folder.to_string());
2559 self.on_account(account, action)?;
2560 Ok(format!("imap:{account}/{folder}"))
2561 }
2562 None => local(expand_tilde(spec)),
2563 }
2564 }
2565
2566 fn on_account(&mut self, account: &str, action: Manage) -> Result<(), String> {
2569 self.settle();
2573 match &mut self.imap {
2574 Some(imap) if imap.facts.account.name == account => imap
2575 .blocking(Job::Manage(action))
2576 .map(drop)
2577 .map_err(|e| format!("{e:#}")),
2578 Some(_) | None => Err("that mailbox is not on the open account".into()),
2579 }
2580 }
2581
2582 pub fn folder_candidates(&mut self) -> Result<Vec<(String, usize)>> {
2583 self.settle();
2587 let mut dirs: Vec<(String, usize)> = self
2590 .config
2591 .mail
2592 .mailboxes
2593 .iter()
2594 .filter(|m| m.starts_with("imap:") || expand_tilde(m).join("cur").is_dir())
2595 .map(|m| {
2596 let count = if m.starts_with("imap:") {
2597 0
2598 } else {
2599 maildir::new_count(&expand_tilde(m))
2600 };
2601 (m.clone(), count)
2602 })
2603 .collect();
2604 match &mut self.imap {
2605 Some(imap) => {
2606 let account = imap.facts.account.name.clone();
2607 if let Done::Folders(folders) = imap.blocking(Job::Folders)? {
2608 dirs.extend(
2609 folders
2610 .into_iter()
2611 .map(|(f, unseen)| (format!("imap:{account}/{f}"), unseen)),
2612 );
2613 }
2614 }
2615 None => dirs.extend(
2616 maildir::discover(&self.dir)
2617 .iter()
2618 .map(|p| (p.display().to_string(), maildir::new_count(p))),
2619 ),
2620 }
2621 let mut seen: HashMap<String, usize> = HashMap::new();
2624 let mut unique: Vec<(String, usize)> = Vec::new();
2625 for (name, count) in dirs {
2626 match seen.get(&name) {
2627 Some(&at) => unique[at].1 = unique[at].1.max(count),
2628 None => {
2629 seen.insert(name.clone(), unique.len());
2630 unique.push((name, count));
2631 }
2632 }
2633 }
2634 let mut dirs = unique;
2635 sort_browser(&mut dirs, self.config.ui.sort_browser.as_deref());
2636 dirs.dedup_by(|a, b| {
2639 if a.0 == b.0 {
2640 b.1 = b.1.max(a.1);
2641 true
2642 } else {
2643 false
2644 }
2645 });
2646 Ok(dirs)
2647 }
2648
2649 pub fn unseen_count(&self, spec: &str) -> usize {
2658 match remote::parse_spec(spec) {
2659 Some(_) => self.unseen.get(spec).copied().unwrap_or(0),
2660 None => maildir::new_count(&expand_tilde(spec)),
2661 }
2662 }
2663
2664 fn refresh_unseen(&mut self) -> bool {
2667 let Some(imap) = &self.imap else {
2668 return false;
2669 };
2670 let account = imap.facts.account.name.clone();
2671 let wanted: Vec<(String, String)> = self
2672 .config
2673 .mail
2674 .mailboxes
2675 .iter()
2676 .filter_map(|spec| match remote::parse_spec(spec) {
2677 Some((a, folder)) if a == account => Some((spec.clone(), folder.to_string())),
2678 _ => None,
2679 })
2680 .collect();
2681 if wanted.is_empty() {
2682 return false;
2683 }
2684 let (specs, folders): (Vec<String>, Vec<String>) = wanted.into_iter().unzip();
2685 self.start(Job::Unseen(folders), Pending::Counts(specs))
2686 }
2687
2688 pub fn store_edited(&mut self, path: &Path, edited: &[u8]) {
2693 self.settle();
2697 match &mut self.imap {
2698 Some(imap) => {
2699 let Some(flags) = self
2700 .visible
2701 .get(self.sel)
2702 .map(|&i| self.msgs[i].env.file.flags)
2703 else {
2704 return;
2705 };
2706 let mailbox = imap.facts.mailbox.clone();
2707 if let Err(err) = imap.blocking(Job::Append {
2708 mailbox: Some(mailbox),
2709 flags,
2710 body: edited.to_vec(),
2711 }) {
2712 self.error(format!("cannot store the edited copy: {err:#}"));
2713 return;
2714 }
2715 let rules = self.delete_rules();
2716 if let Some(m) = self.cur_mut()
2717 && rules.mark(m)
2718 {
2719 m.dirty = true;
2720 }
2721 self.check_new_mail();
2722 self.note("edited copy appended; original marked deleted ($ purges)");
2723 }
2724 None => {
2725 if let Err(err) = std::fs::write(path, edited) {
2726 self.error(format!("cannot write message: {err}"));
2727 return;
2728 }
2729 self.rescan();
2730 self.note("message edited");
2731 }
2732 }
2733 }
2734
2735 pub fn mark_old_unread(&mut self) {
2739 if self.read_only || !self.config.mail.mark_old.unwrap_or(true) {
2740 return;
2741 }
2742 for m in &mut self.msgs {
2743 if m.env.file.is_new
2744 && !m.env.file.flags.seen
2745 && !m.env.file.flags.deleted
2746 && let Ok(path) = maildir::store_flags(&m.env.file)
2747 {
2748 m.env.file.path = path;
2749 m.env.file.is_new = false;
2750 }
2751 }
2752 }
2753
2754 pub fn ready_to_leave(&mut self) -> bool {
2758 if self.deleted_count() > 0 {
2759 self.error("deleted messages pending; sync with $ or undelete first");
2760 return false;
2761 }
2762 if self.pending_count() > 0 {
2763 self.sync(false);
2764 if self.pending_count() > 0 {
2765 return false; }
2767 }
2768 true
2769 }
2770
2771 pub fn smtp_account(&self) -> Option<Account> {
2775 if std::env::var("RMUT_SENDMAIL").is_ok() || self.config.mail.sendmail.is_some() {
2776 return None;
2777 }
2778 if let Some(imap) = &self.imap
2779 && imap.facts.account.smtp_host.is_some()
2780 {
2781 return Some(imap.facts.account.clone());
2782 }
2783 self.config
2784 .accounts
2785 .iter()
2786 .find(|a| a.smtp_host.is_some())
2787 .cloned()
2788 }
2789
2790 pub fn copy_message(&mut self, input: &str, delete: bool, tagged: bool, decode: bool) {
2794 if input.is_empty() {
2795 self.error("no mailbox given");
2796 return;
2797 }
2798 let targets = self.op_targets(tagged);
2799 if targets.is_empty() {
2800 return;
2801 }
2802 let paths = self.target_paths(tagged);
2804 if !self.have_bodies(
2805 &paths,
2806 Again::Copy {
2807 input: input.to_string(),
2808 delete,
2809 tagged,
2810 decode,
2811 },
2812 ) {
2813 return;
2814 }
2815 let mut created: Vec<PathBuf> = Vec::new();
2818 let mut marks = Vec::new();
2819 let mut copied = Vec::new();
2820 let mut errors: Vec<String> = Vec::new();
2821 let mut note = None;
2822 let mut target = String::new();
2823 for &i in &targets {
2824 match self.copy_one(i, input, decode, &mut created) {
2825 Ok(shown) => {
2826 if shown.starts_with("imap:") {
2827 note = Some(format!("the copy in {shown} stays"));
2828 }
2829 target = shown;
2830 marks.push(self.mark(i));
2831 copied.push(i);
2832 }
2833 Err(err) => errors.push(err),
2836 }
2837 }
2838 let verb = if delete { "save" } else { "copy" };
2839 if copied.is_empty() {
2840 self.error(format!("cannot {verb}: {}", errors.join("; ")));
2841 return;
2842 }
2843 self.push_undo_step(UndoStep {
2844 what: format!("{verb} to {target}"),
2845 marks,
2846 sel: self.selected_path(),
2847 created,
2848 note,
2849 rewritten: Vec::new(),
2850 });
2851 let n = copied.len();
2852 if delete {
2853 let rules = self.delete_rules();
2854 for &i in &copied {
2855 if rules.mark(&mut self.msgs[i]) {
2856 self.msgs[i].dirty = true;
2857 }
2858 }
2859 }
2860 let mut status = match (delete, n) {
2861 (true, 1) => format!("saved to {target} (original marked deleted)"),
2862 (true, _) => format!("saved {n} to {target} (originals marked deleted)"),
2863 (false, 1) => format!("copied to {target}"),
2864 (false, _) => format!("copied {n} to {target}"),
2865 };
2866 if !errors.is_empty() {
2867 status += &format!("; {} failed: {}", errors.len(), errors.join("; "));
2868 self.error(status);
2869 } else {
2870 self.note(status);
2871 }
2872 }
2873
2874 fn copy_one(
2878 &mut self,
2879 i: usize,
2880 spec: &str,
2881 decode: bool,
2882 created: &mut Vec<PathBuf>,
2883 ) -> Result<String, String> {
2884 self.settle();
2888 let flags = self.msgs[i].env.file.flags;
2889 let bytes = if decode {
2893 self.displayed_text(i)?.into_bytes()
2894 } else {
2895 self.message_bytes(i).ok_or("cannot read the message")?
2896 };
2897 match remote::parse_spec(spec) {
2898 Some((account, folder)) => match &mut self.imap {
2899 Some(imap) if imap.facts.account.name == account => imap
2900 .blocking(Job::Append {
2901 mailbox: Some(folder.to_string()),
2902 flags,
2903 body: bytes,
2904 })
2905 .map(|done| match done {
2906 Done::Folder(folder) => format!("imap:{account}/{folder}"),
2907 _ => format!("imap:{account}/{folder}"),
2908 })
2909 .map_err(|err| format!("{err:#}")),
2910 _ => Err("can only save to a folder of the open account".into()),
2911 },
2912 None => {
2913 let dir = expand_tilde(spec);
2914 maildir::create(&dir)
2915 .and_then(|()| maildir::deliver(&dir, &bytes, flags))
2916 .map(|path| {
2917 created.push(path);
2918 dir.display().to_string()
2919 })
2920 .map_err(|err| format!("{err:#}"))
2921 }
2922 }
2923 }
2924
2925 pub fn create_alias(&mut self, nick: &str, addr: &str) {
2927 if nick.is_empty() || nick.contains(char::is_whitespace) {
2928 self.error("the alias nick must be one word");
2929 return;
2930 }
2931 match alias::append_to(self.config.mail.alias_file.as_deref(), nick, addr) {
2932 Ok(_) => self.note(format!("added: alias {nick} {addr}")),
2933 Err(err) => self.error(format!("cannot save the alias: {err:#}")),
2934 }
2935 }
2936
2937 pub fn pipe_message(&mut self, command: &str, tagged: bool) {
2941 if command.is_empty() {
2942 self.error("no command given");
2943 return;
2944 }
2945 let paths = self.target_paths(tagged);
2946 if !self.have_bodies(
2947 &paths,
2948 Again::Pipe {
2949 command: command.to_string(),
2950 tagged,
2951 },
2952 ) {
2953 return;
2954 }
2955 let targets = self.op_targets(tagged);
2956 let n = targets.len();
2957 let decode = self.config.mail.pipe_decode.unwrap_or(false);
2958 let split = self.config.mail.pipe_split.unwrap_or(false);
2959 let sep = self
2960 .config
2961 .mail
2962 .pipe_sep
2963 .clone()
2964 .unwrap_or_else(|| "\n".into());
2965 match self.run_over(&targets, command, decode, split, &sep) {
2966 Ok(()) => self.note(match n {
2967 1 => format!("piped to {command}"),
2968 _ => format!("piped {n} messages to {command}"),
2969 }),
2970 Err(err) => self.error(format!("pipe failed: {err}")),
2971 }
2972 }
2973
2974 pub fn bounce_current(&mut self, to: &str, tagged: bool) {
2977 let rcpts = compose::addresses(to);
2978 if rcpts.is_empty() {
2979 self.error(format!("cannot parse the addresses in {to:?}"));
2980 return;
2981 }
2982 let paths = self.target_paths(tagged);
2983 if !self.have_bodies(
2984 &paths,
2985 Again::Bounce {
2986 to: to.to_string(),
2987 tagged,
2988 },
2989 ) {
2990 return;
2991 }
2992 let targets = self.op_targets(tagged);
2993 let mut sent = 0usize;
2994 for i in targets {
2995 let Some(bytes) = self.message_bytes(i) else {
2996 return;
2997 };
2998 if let Err(err) = self.bounce_one(&bytes, to, &rcpts) {
2999 self.error(format!("bounce failed: {err:#}"));
3000 return;
3001 }
3002 sent += 1;
3003 }
3004 self.note(match sent {
3005 1 => format!("message bounced to {to}"),
3006 _ => format!("{sent} messages bounced to {to}"),
3007 });
3008 }
3009
3010 fn bounce_one(&mut self, bytes: &[u8], to: &str, rcpts: &[String]) -> Result<()> {
3013 let host = maildir::hostname();
3014 let from = self
3015 .current_identity(rcpts)
3016 .from_line()
3017 .unwrap_or_else(|| default_from(&host));
3018 let text = compose::bounce_text(
3019 bytes,
3020 &from,
3021 to,
3022 &compose::rfc2822_now(),
3023 &compose::make_message_id(&host),
3024 );
3025 let envelope_from = compose::bare_address(&from).unwrap_or_else(|| from.clone());
3026 match self.smtp_account() {
3027 Some(account) => account_password(&account).and_then(|password| {
3028 smtp::send(&account, &password, &envelope_from, rcpts, text.as_bytes())
3029 }),
3030 None => run_sendmail(
3031 text.as_bytes(),
3032 self.config.mail.sendmail.as_deref(),
3033 Some(rcpts),
3034 ),
3035 }
3036 }
3037
3038 pub fn open_message(&mut self) {
3041 let Some(path) = self.selected_path() else {
3042 return;
3043 };
3044 if !self.have_bodies(std::slice::from_ref(&path), Again::View) {
3045 return;
3046 }
3047 self.mark_read();
3048 match self.load_view(&path) {
3049 Ok(view) => self.requests.push(Request::ShowMessage(Box::new(view))),
3050 Err(err) => self.error(format!("cannot open message: {err:#}")),
3051 }
3052 }
3053
3054 pub fn load_view(&mut self, path: &Path) -> Result<message::MessageView> {
3057 self.settle();
3061 if remote::is_partial(path)
3063 && let Some(imap) = &mut self.imap
3064 {
3065 imap.blocking(Job::FetchBodies(vec![path.to_path_buf()]))
3066 .context("cannot fetch message")?;
3067 if let Some(m) = self.msgs.iter_mut().find(|m| m.env.file.path == path)
3069 && let Ok(raw) = std::fs::read(path)
3070 {
3071 m.env.lines = Some(message::body_lines(&raw));
3072 }
3073 }
3074 let mut view = message::load_with(path, &self.display)?;
3075 if let Ok(raw) = std::fs::read(path)
3078 && let Some(p) = pgp::view(&self.config.pgp, &raw)
3079 {
3080 match p.body {
3081 Some(pgp::Body::Entity(raw)) => {
3085 view.body = message::render_entity(&raw, &self.display);
3086 }
3087 Some(pgp::Body::Text(text)) => view.body = text,
3088 None => {}
3089 }
3090 view.body = format!("{}\n\n{}", p.note, view.body);
3091 }
3092 Ok(view)
3093 }
3094
3095 fn displayed_text(&mut self, i: usize) -> Result<String, String> {
3099 let path = self.msgs[i].env.file.path.clone();
3100 let view = self
3101 .load_view(&path)
3102 .map_err(|err| format!("cannot decode: {err:#}"))?;
3103 let mut text = String::new();
3104 for (name, value) in &view.brief {
3105 text += &format!("{name}: {value}\n");
3106 }
3107 text.push('\n');
3108 text += &view.body;
3109 if !text.ends_with('\n') {
3110 text.push('\n');
3111 }
3112 Ok(text)
3113 }
3114
3115 pub fn print_current(&mut self, tagged: bool) {
3119 let paths = self.target_paths(tagged);
3120 if !self.have_bodies(&paths, Again::Print { tagged }) {
3121 return;
3122 }
3123 let targets = self.op_targets(tagged);
3124 if targets.is_empty() {
3125 return;
3126 }
3127 let command = self
3128 .config
3129 .mail
3130 .print
3131 .clone()
3132 .unwrap_or_else(|| "lpr".into());
3133 let decode = self.config.mail.print_decode.unwrap_or(true);
3134 let split = self.config.mail.print_split.unwrap_or(false);
3135 let n = targets.len();
3136 match self.run_over(&targets, &command, decode, split, "\n") {
3137 Ok(()) => self.note(match n {
3138 1 => format!("printed via {command}"),
3139 _ => format!("printed {n} messages via {command}"),
3140 }),
3141 Err(err) => self.error(format!("print failed: {err}")),
3142 }
3143 }
3144
3145 fn op_one_bytes(&mut self, i: usize, decode: bool) -> Result<Vec<u8>, String> {
3148 if decode {
3149 self.displayed_text(i).map(String::into_bytes)
3150 } else {
3151 self.message_bytes(i)
3152 .map(|mut b| {
3153 if !b.ends_with(b"\n") {
3154 b.push(b'\n');
3155 }
3156 b
3157 })
3158 .ok_or_else(|| "cannot read the message".into())
3159 }
3160 }
3161
3162 fn run_over(
3166 &mut self,
3167 targets: &[usize],
3168 command: &str,
3169 decode: bool,
3170 split: bool,
3171 sep: &str,
3172 ) -> Result<(), String> {
3173 if split {
3174 for &i in targets {
3175 let bytes = self.op_one_bytes(i, decode)?;
3176 pipe_to(command, &bytes).map_err(|err| format!("{err:#}"))?;
3177 }
3178 return Ok(());
3179 }
3180 let mut all: Vec<u8> = Vec::new();
3181 for (n, &i) in targets.iter().enumerate() {
3182 if n > 0 {
3183 all.extend_from_slice(sep.as_bytes());
3184 }
3185 all.extend_from_slice(&self.op_one_bytes(i, decode)?);
3186 }
3187 pipe_to(command, &all).map_err(|err| format!("{err:#}"))
3188 }
3189
3190 pub fn current_identity(&self, rcpts: &[String]) -> rmut_core::config::Identity {
3193 self.config.identity_for(
3194 &self.title,
3195 rcpts,
3196 self.imap.as_ref().map(|imap| &imap.facts.account),
3197 )
3198 }
3199
3200 pub fn matching_message_hooks(&self) -> Vec<usize> {
3202 let Some(env) = self.visible.get(self.sel).map(|&mi| &self.msgs[mi].env) else {
3203 return Vec::new();
3204 };
3205 let pos = pattern::Position {
3208 number: self.sel + 1,
3209 current: self.sel + 1,
3210 last: self.visible.len(),
3211 duplicate: false,
3212 };
3213 self.message_hooks
3214 .iter()
3215 .enumerate()
3216 .filter(|(_, h)| pattern::matches_in(&h.patterns, env, self.scope(pos), None))
3217 .map(|(i, _)| i)
3218 .collect()
3219 }
3220
3221 pub fn reply_hook_lines(&self, path: &Path) -> Vec<String> {
3225 if self.reply_hooks.is_empty() {
3226 return Vec::new();
3227 }
3228 let Some(env) = self.msgs.iter().find(|m| m.env.file.path == *path) else {
3229 return Vec::new();
3230 };
3231 let scope = self.scope(pattern::Position::default());
3232 self.reply_hooks
3233 .iter()
3234 .filter(|h| pattern::matches_in(&h.patterns, &env.env, scope, None))
3235 .map(|h| h.value.clone())
3236 .collect()
3237 }
3238
3239 pub fn fcc_hook_target(&self, draft: &str, path: &Path) -> Option<String> {
3242 if self.fcc_hooks.is_empty() {
3243 return None;
3244 }
3245 let env = compose::draft_envelope(draft, path);
3246 let scope = self.scope(pattern::Position::default());
3247 self.fcc_hooks
3248 .iter()
3249 .find(|h| pattern::matches_in(&h.patterns, &env, scope, None))
3250 .map(|h| h.value.clone())
3251 }
3252
3253 pub fn crypt_key_for(&self, address: &str) -> Option<String> {
3256 self.crypt_hooks
3257 .iter()
3258 .find(|(m, _)| m.is_match(address))
3259 .map(|(_, key)| key.clone())
3260 }
3261
3262 pub fn notmuch_mirror(&mut self, query: &str) -> Option<(PathBuf, usize)> {
3270 if query.is_empty() {
3271 return None;
3272 }
3273 let out = Command::new("notmuch")
3274 .args(["search", "--output=files", "--limit=1000", "--", query])
3275 .output();
3276 let out = match out {
3277 Ok(out) if out.status.success() => out,
3278 Ok(out) => {
3279 let err = String::from_utf8_lossy(&out.stderr);
3280 self.error(format!("notmuch: {}", err.trim()));
3281 return None;
3282 }
3283 Err(err) => {
3284 self.error(format!("notmuch: {err}"));
3285 return None;
3286 }
3287 };
3288 let stdout = String::from_utf8_lossy(&out.stdout);
3289 let files: Vec<&str> = stdout.lines().filter(|l| !l.trim().is_empty()).collect();
3290 if files.is_empty() {
3291 self.error("notmuch: no matches");
3292 return None;
3293 }
3294 if !self.ready_to_leave() {
3295 return None;
3296 }
3297 let dir = remote::cache_base().join("notmuch");
3298 let build = || -> Result<()> {
3299 for sub in ["cur", "new", "tmp"] {
3300 std::fs::create_dir_all(dir.join(sub))?;
3301 }
3302 for entry in std::fs::read_dir(dir.join("cur"))?.flatten() {
3303 let _ = std::fs::remove_file(entry.path());
3304 }
3305 for (i, file) in files.iter().enumerate() {
3306 let base = Path::new(file)
3307 .file_name()
3308 .map(|n| n.to_string_lossy().into_owned())
3309 .unwrap_or_else(|| format!("{i}"));
3310 let _ = std::os::unix::fs::symlink(
3313 file,
3314 dir.join("cur").join(format!("{i:04}.{base}")),
3315 );
3316 }
3317 Ok(())
3318 };
3319 if let Err(err) = build() {
3320 self.note(format!("notmuch mirror: {err:#}"));
3321 return None;
3322 }
3323 Some((dir, files.len()))
3324 }
3325
3326 pub fn compose_base(&self) -> Option<ComposeBase> {
3327 let &mi = self.visible.get(self.sel)?;
3328 let env = &self.msgs[mi].env;
3329 let view = message::load(&env.file.path).ok()?;
3330 let get = |name: &str| {
3331 view.all
3332 .iter()
3333 .filter(|(k, _)| k.eq_ignore_ascii_case(name))
3334 .map(|(_, v)| v.clone())
3335 .collect::<Vec<_>>()
3336 .join(", ")
3337 };
3338 let from_hdr = get("From");
3339 let (reply_to, has_reply_to) = {
3340 let rt = get("Reply-To");
3341 if rt.trim().is_empty() {
3342 (from_hdr.clone(), false)
3343 } else {
3344 let differs = rt.trim() != from_hdr.trim();
3345 (rt, differs)
3346 }
3347 };
3348 Some(ComposeBase {
3349 path: env.file.path.clone(),
3350 reply_to,
3351 from_hdr,
3352 has_reply_to,
3353 orig_to: get("To"),
3354 orig_cc: get("Cc"),
3355 list_post: compose::list_post_address(&get("List-Post")),
3356 followup_to: get("Mail-Followup-To"),
3357 from_addr: compose::addresses(&get("From"))
3358 .into_iter()
3359 .next()
3360 .unwrap_or_default(),
3361 from_display: env.from.clone(),
3362 subject: env.subject.clone(),
3363 date: env.date,
3364 msg_id: env.msg_id.clone(),
3365 references: env.references.clone(),
3366 })
3367 }
3368
3369 pub fn list_target(&self, base: &ComposeBase) -> Option<String> {
3375 if let Some(addr) = &base.list_post {
3376 return Some(addr.clone());
3377 }
3378 let mut candidates = compose::addresses(&base.orig_to);
3379 candidates.extend(compose::addresses(&base.orig_cc));
3380 candidates
3381 .into_iter()
3382 .find(|a| self.lists.iter().any(|m| m.is_match(a)))
3383 }
3384
3385 pub fn followup_header(&self, to: &str, cc: Option<&str>, from: &str) -> Option<String> {
3389 if self.lists.is_empty() {
3390 return None;
3391 }
3392 let mut rcpts = compose::addresses(to);
3393 rcpts.extend(compose::addresses(cc.unwrap_or_default()));
3394 if !rcpts
3395 .iter()
3396 .any(|a| self.lists.iter().any(|m| m.is_match(a)))
3397 {
3398 return None;
3399 }
3400 let subscribed = rcpts
3401 .iter()
3402 .any(|a| self.subscribed.iter().any(|m| m.is_match(a)));
3403 let value = compose::followup_to(to, cc.unwrap_or_default(), self.me(), subscribed, from);
3404 (!value.is_empty()).then_some(value)
3405 }
3406
3407 pub fn edit_headers(&self) -> bool {
3410 self.config.mail.edit_headers.unwrap_or(false)
3411 }
3412
3413 pub fn stage_draft(&self, text: &str) -> Result<(PathBuf, Option<String>)> {
3417 let text = compose::apply_my_hdr(text, &self.config.mail.my_hdr);
3421 if self.edit_headers() {
3422 return Ok((write_draft(&text)?, None));
3423 }
3424 let (head, body) = text.split_once("\n\n").unwrap_or((text.trim_end(), ""));
3425 Ok((write_draft(body)?, Some(head.to_string())))
3426 }
3427
3428 pub fn compose_from(&self, base: Option<&ComposeBase>, to: &str) -> Option<String> {
3432 let realname = self.config.identity.reverse_realname.unwrap_or(true);
3436 if self.config.identity.reverse_name
3437 && let Some(b) = base
3438 && let Some(from) = compose::reverse_from(&b.orig_to, &b.orig_cc, self.me(), realname)
3439 {
3440 let rcpts = compose::addresses(to);
3441 return Some(match self.current_identity(&rcpts).name {
3442 Some(name) if !realname && !from.contains('<') => format!("{name} <{from}>"),
3443 _ => from,
3444 });
3445 }
3446 let rcpts = compose::addresses(to);
3447 self.current_identity(&rcpts).from_line()
3448 }
3449
3450 pub fn forward_attaches(&self) -> bool {
3451 self.config.mail.forward.as_deref() == Some("attach")
3452 }
3453
3454 pub fn default_fcc(&self, draft: Option<&Compose>) -> String {
3462 if let Some(compose) = draft
3463 && let Ok(full) = draft_full(compose)
3464 && let Some(mailbox) = self.fcc_hook_target(&full, &compose.path)
3465 {
3466 return mailbox;
3467 }
3468 match &self.imap {
3469 Some(imap) => format!(
3470 "imap:{}/{}",
3471 imap.facts.account.name, imap.facts.account.sent_folder
3472 ),
3473 None => self.config.mail.sent.clone().unwrap_or_default(),
3474 }
3475 }
3476
3477 pub fn default_security(&self) -> Security {
3479 Self::combine(
3480 self.config.pgp.sign_by_default,
3481 self.config.pgp.encrypt_by_default,
3482 )
3483 }
3484
3485 fn combine(sign: bool, encrypt: bool) -> Security {
3486 match (sign, encrypt) {
3487 (true, true) => Security::Both,
3488 (true, false) => Security::Sign,
3489 (false, true) => Security::Encrypt,
3490 (false, false) => Security::None,
3491 }
3492 }
3493
3494 pub fn security_for(&self, kind: &ComposeKind, base: Option<&ComposeBase>) -> Security {
3501 let mut sign = self.config.pgp.sign_by_default;
3502 let mut encrypt = self.config.pgp.encrypt_by_default;
3503 let is_reply = matches!(
3504 kind,
3505 ComposeKind::Reply | ComposeKind::GroupReply | ComposeKind::ListReply
3506 );
3507 if is_reply
3508 && let Some(base) = base
3509 && let Ok(raw) = std::fs::read(&base.path)
3510 {
3511 let crypto = rmut_core::pgp::classify(&raw);
3512 if crypto.encrypted && self.config.pgp.reply_encrypt {
3513 encrypt = true;
3514 }
3515 if crypto.signed && self.config.pgp.reply_sign {
3516 sign = true;
3517 }
3518 if crypto.encrypted && self.config.pgp.reply_sign_encrypted {
3524 sign = true;
3525 }
3526 }
3527 Self::combine(sign, encrypt)
3528 }
3529
3530 pub fn tick_outbox(&mut self) {
3533 while self.outbox.first().is_some_and(|h| h.due <= Instant::now()) {
3534 let held = self.outbox.remove(0);
3535 self.deliver(held);
3536 }
3537 if let Some(next) = self.outbox.first()
3538 && !self.notice().is_some_and(|n| n.is_error())
3539 {
3540 let left = next.due.saturating_duration_since(Instant::now()).as_secs() + 1;
3541 self.note(format!("sending {} in {left}s (z cancels)", next.label));
3542 }
3543 }
3544
3545 pub fn flush_outbox(&mut self) -> Vec<String> {
3552 let mut trouble = Vec::new();
3553 while !self.outbox.is_empty() {
3554 let held = self.outbox.remove(0);
3555 let label = held.label.clone();
3556 self.deliver(held);
3557 if let Some(err) = self.notice().filter(|n| n.is_error()).map(|n| n.text()) {
3558 trouble.push(format!("{label}: {err}"));
3559 self.clear_notice();
3560 }
3561 }
3562 trouble
3563 }
3564
3565 pub fn hold_send(&mut self, held: Held) {
3569 self.outbox.push(held);
3570 }
3571
3572 pub fn cancel_send(&mut self) -> bool {
3575 let Some(held) = self.outbox.pop() else {
3576 return false;
3577 };
3578 self.note(format!("send cancelled: {}", held.label));
3579 self.hand_back(held.state);
3580 true
3581 }
3582
3583 pub fn deliver(&mut self, held: Held) {
3588 self.settle();
3592 let Held {
3593 state: compose_state,
3594 text: final_text,
3595 fcc: chosen,
3596 ..
3597 } = held;
3598 let send_result = match self.smtp_account() {
3599 Some(account) => send_via_smtp(&account, &final_text),
3600 None => run_sendmail(
3601 final_text.as_bytes(),
3602 self.config.mail.sendmail.as_deref(),
3603 None,
3604 ),
3605 };
3606 match send_result {
3607 Ok(()) => {
3608 let mut note = String::from("message sent");
3609 let skip_copy = chosen.as_deref() == Some("")
3610 || (chosen.is_none() && self.config.mail.copy == Some(false));
3611 if skip_copy {
3612 } else if let Some(fcc) = chosen
3614 .as_deref()
3615 .filter(|f| Some(*f) != Some(self.default_fcc(None).as_str()))
3616 {
3617 let dir = expand_tilde(fcc);
3619 let flags = maildir::Flags {
3620 seen: true,
3621 ..Default::default()
3622 };
3623 if dir.join("cur").is_dir()
3624 && maildir::deliver(&dir, final_text.as_bytes(), flags).is_ok()
3625 {
3626 note += &format!(", copy in {fcc}");
3627 } else {
3628 note += &format!(", Fcc to {fcc} failed");
3629 }
3630 } else {
3631 match &mut self.imap {
3632 Some(imap) => match imap.blocking(Job::Append {
3635 mailbox: None,
3636 flags: maildir::Flags {
3637 seen: true,
3638 ..Default::default()
3639 },
3640 body: final_text.clone().into_bytes(),
3641 }) {
3642 Ok(Done::Folder(folder)) => note += &format!(", copy in {folder}"),
3643 Ok(_) => note += ", copy in Sent",
3644 Err(_) => note += ", Fcc to Sent failed",
3645 },
3646 None => {
3647 let sent_dir = self
3648 .config
3649 .mail
3650 .sent
3651 .as_deref()
3652 .map(expand_tilde)
3653 .filter(|p| p.join("cur").is_dir())
3654 .or_else(|| {
3655 maildir::find_special(&self.dir, &["sent", "sent-mail"])
3656 });
3657 match sent_dir {
3658 Some(sent) => {
3659 let flags = maildir::Flags {
3660 seen: true,
3661 ..Default::default()
3662 };
3663 match maildir::deliver(&sent, final_text.as_bytes(), flags) {
3664 Ok(_) => note += ", copy in Sent",
3665 Err(_) => note += ", Fcc to Sent failed",
3666 }
3667 }
3668 None => note += " (no Sent maildir, no copy kept)",
3669 }
3670 }
3671 }
3672 }
3673 if let Ok(full) = draft_full(&compose_state) {
3675 for a in compose::extract_attachments(&full).1 {
3676 if a.unlink {
3677 let _ = std::fs::remove_file(&a.path);
3678 }
3679 }
3680 }
3681 let _ = std::fs::remove_file(&compose_state.path);
3682 if let Some(src) = &compose_state.recall_source {
3683 let _ = std::fs::remove_file(src);
3684 }
3685 self.note(note);
3686 }
3687 Err(err) => {
3688 self.error(format!("send failed: {err:#}"));
3689 self.hand_back(compose_state);
3690 }
3691 }
3692 }
3693
3694 pub fn attachment_forgotten(&self, raw: &str, compose: &Compose) -> bool {
3699 if self.config.mail.abort_noattach.as_deref().unwrap_or("no") == "no" {
3700 return false;
3701 }
3702 if compose.attach.is_some() || !compose::extract_attachments(raw).1.is_empty() {
3703 return false;
3704 }
3705 let body = raw.split_once("\n\n").map_or("", |(_, body)| body);
3706 for line in body.lines() {
3707 if line.trim_end() == "--" || line == "-- " {
3708 break;
3709 }
3710 if self.quote_re.is_match(line) {
3711 continue;
3712 }
3713 if self.attach_re.is_match(line) {
3714 return true;
3715 }
3716 }
3717 false
3718 }
3719
3720 pub fn secure_message(
3724 &self,
3725 security: Security,
3726 text: String,
3727 files: &[compose::Attachment],
3728 original: Option<&[u8]>,
3729 ) -> Result<String> {
3730 let cfg = &self.config.pgp;
3731 let recipients = |text: &str| -> Result<Vec<String>> {
3734 let (mut rcpts, _) = compose::smtp_envelope(text)?;
3735 if let Some(from) = compose::from_address(text) {
3736 rcpts.push(from);
3737 }
3738 for r in &mut rcpts {
3741 if let Some(key) = self.crypt_key_for(r) {
3742 *r = key;
3743 }
3744 }
3745 rcpts.sort();
3746 rcpts.dedup();
3747 Ok(rcpts)
3748 };
3749 let flowed = self.config.mail.text_flowed;
3750 if files.is_empty() && original.is_none() {
3751 return match security {
3752 Security::None if flowed => Ok(compose::flow_plain(&text)),
3755 Security::None => Ok(text),
3756 Security::Sign => pgp::sign_message(cfg, &text, flowed),
3757 Security::Encrypt | Security::Both => pgp::encrypt_message(
3758 cfg,
3759 &recipients(&text)?,
3760 security == Security::Both,
3761 &text,
3762 flowed,
3763 ),
3764 };
3765 }
3766 let (head, body) = text.split_once("\n\n").unwrap_or((text.trim_end(), ""));
3767 let entity = compose::mixed_entity(body, files, original, flowed)?;
3768 match security {
3769 Security::None => Ok(format!("{}\nMIME-Version: 1.0\n{entity}", head.trim_end())),
3770 Security::Sign => pgp::sign_entity(cfg, head, entity.as_bytes()),
3771 Security::Encrypt | Security::Both => pgp::encrypt_entity(
3772 cfg,
3773 &recipients(&text)?,
3774 security == Security::Both,
3775 head,
3776 entity.as_bytes(),
3777 ),
3778 }
3779 }
3780
3781 pub fn postponed_dir(&self) -> Option<PathBuf> {
3782 self.config
3783 .mail
3784 .postponed
3785 .as_deref()
3786 .map(expand_tilde)
3787 .filter(|p| p.join("cur").is_dir())
3788 .or_else(|| {
3789 maildir::find_special(&self.dir, &["drafts", "postponed", "rmut-postponed"])
3790 })
3791 }
3792
3793 pub fn has_postponed(&self) -> bool {
3794 self.postponed_dir()
3795 .and_then(|d| maildir::scan(&d).ok())
3796 .is_some_and(|files| !files.is_empty())
3797 }
3798
3799 pub fn postpone_draft(&mut self, compose_state: Compose) {
3802 let target = match self.postponed_dir() {
3803 Some(d) => Ok(d),
3804 None => {
3805 let d = self.dir.join(".rmut-postponed");
3806 maildir::create(&d).map(|()| d)
3807 }
3808 };
3809 let result = target.and_then(|dir| {
3810 let bytes = draft_full(&compose_state)?.into_bytes();
3813 let flags = maildir::Flags {
3814 draft: true,
3815 seen: true,
3816 ..Default::default()
3817 };
3818 maildir::deliver(&dir, &bytes, flags)
3819 });
3820 match result {
3821 Ok(path) => {
3822 let _ = std::fs::remove_file(&compose_state.path);
3823 self.note(format!("postponed to {}", path.display()));
3824 }
3825 Err(err) => {
3826 self.error(format!(
3827 "postpone failed: {err:#}; draft at {}",
3828 compose_state.path.display()
3829 ));
3830 }
3831 }
3832 }
3833
3834 pub fn recall_file(&mut self, source: PathBuf) -> Option<Compose> {
3837 let result = std::fs::read_to_string(&source)
3838 .map_err(anyhow::Error::from)
3839 .and_then(|content| self.stage_draft(&content));
3840 match result {
3841 Ok((path, hidden_head)) => Some(Compose {
3842 path,
3843 recall_source: Some(source),
3844 security: self.default_security(),
3845 attach: None,
3846 hidden_head,
3847 fcc: None,
3848 }),
3849 Err(err) => {
3850 self.error(format!("cannot recall: {err:#}"));
3851 None
3852 }
3853 }
3854 }
3855
3856 pub fn draft_head(&self) -> String {
3858 let Some(c) = &self.draft else {
3859 return String::new();
3860 };
3861 match &c.hidden_head {
3862 Some(head) => head.clone(),
3863 None => {
3864 let text = std::fs::read_to_string(&c.path).unwrap_or_default();
3865 match text.split_once("\n\n") {
3866 Some((head, _)) => head.to_string(),
3867 None => text.trim_end().to_string(),
3868 }
3869 }
3870 }
3871 }
3872
3873 pub fn edit_draft_head(&mut self, f: impl Fn(&str) -> String) {
3875 let Some(c) = &mut self.draft else {
3876 return;
3877 };
3878 match &mut c.hidden_head {
3879 Some(head) => *head = f(head),
3880 None => {
3881 if let Ok(text) = std::fs::read_to_string(&c.path) {
3882 let (head, body) = text.split_once("\n\n").unwrap_or((text.trim_end(), ""));
3883 let _ = std::fs::write(&c.path, format!("{}\n\n{body}", f(head)));
3884 }
3885 }
3886 }
3887 }
3888
3889 pub fn set_draft_header(&mut self, name: &str, value: &str) {
3891 let value = value.trim().to_string();
3892 let name = name.to_string();
3893 self.edit_draft_head(|head| {
3894 let mut lines: Vec<&str> = head
3895 .lines()
3896 .filter(|l| {
3897 !l.split_once(':')
3898 .is_some_and(|(k, _)| k.trim().eq_ignore_ascii_case(&name))
3899 })
3900 .collect();
3901 let added = format!("{name}: {value}");
3902 if !value.is_empty() {
3903 lines.push(&added);
3904 }
3905 lines.join("\n")
3906 });
3907 }
3908
3909 pub fn draft_header(&self, name: &str) -> String {
3910 header_value(&self.draft_head(), name).unwrap_or_default()
3911 }
3912
3913 pub fn send_draft(&mut self) -> Option<Ask> {
3917 let compose_state = self.draft.take()?;
3918 let raw = match draft_full(&compose_state) {
3919 Ok(r) => r,
3920 Err(err) => {
3921 self.error(format!("cannot read draft: {err}"));
3922 return None;
3923 }
3924 };
3925 if !mem::take(&mut self.attach_confirmed) && self.attachment_forgotten(&raw, &compose_state)
3928 {
3929 self.draft = Some(compose_state);
3930 match self.config.mail.abort_noattach.as_deref() {
3931 Some("yes") => {
3934 self.error("no attachment: not sent (abort_noattach); a attaches one");
3935 self.requests.push(Request::ShowDraft);
3936 return None;
3937 }
3938 _ => {
3939 return Some(Ask::Key {
3940 label:
3941 "The body mentions an attachment and none is attached. Send? (y/n): "
3942 .into(),
3943 what: AskKind::NoAttach,
3944 });
3945 }
3946 }
3947 }
3948 let draft_path = self
3951 .draft
3952 .as_ref()
3953 .map(|c| c.path.clone())
3954 .unwrap_or_default();
3955 let hook_fcc = self.fcc_hook_target(&raw, &draft_path);
3956 let (raw, files) = compose::extract_attachments(&raw);
3957 let host = maildir::hostname();
3958 let from = self
3959 .current_identity(&[])
3960 .from_line()
3961 .unwrap_or_else(|| default_from(&host));
3962 let msg_host = self
3964 .config
3965 .mail
3966 .hostname
3967 .clone()
3968 .filter(|h| !h.trim().is_empty())
3969 .unwrap_or(host);
3970 let final_text = match compose::finalize_with(
3971 &raw,
3972 &from,
3973 &compose::make_message_id(&msg_host),
3974 &compose::rfc2822_now(),
3975 self.config.mail.user_agent.unwrap_or(false),
3976 ) {
3977 Ok(t) => t,
3978 Err(err) => {
3979 self.error(format!("{err}; press e to edit"));
3980 self.hand_back(compose_state);
3981 return None;
3982 }
3983 };
3984 let original = match &compose_state.attach {
3985 Some(path) => match std::fs::read(path) {
3986 Ok(bytes) => Some(bytes),
3987 Err(err) => {
3988 self.error(format!("cannot attach the original: {err}"));
3989 self.hand_back(compose_state);
3990 return None;
3991 }
3992 },
3993 None => None,
3994 };
3995 let final_text = match self.secure_message(
3996 compose_state.security,
3997 final_text,
3998 &files,
3999 original.as_deref(),
4000 ) {
4001 Ok(t) => t,
4002 Err(err) => {
4003 self.error(format!("{err:#}; e edits, s changes security"));
4004 self.hand_back(compose_state);
4005 return None;
4006 }
4007 };
4008 let held = Held {
4011 text: final_text,
4012 fcc: compose_state.fcc.clone().or(hook_fcc),
4013 label: {
4014 let head = raw.split_once("\n\n").map_or(raw.as_str(), |(h, _)| h);
4015 header_value(head, "Subject")
4016 .filter(|s| !s.trim().is_empty())
4017 .or_else(|| header_value(head, "To"))
4018 .unwrap_or_else(|| "message".into())
4019 },
4020 state: compose_state,
4021 due: Instant::now() + Duration::from_secs(self.config.mail.undo_send),
4022 };
4023 if self.config.mail.undo_send > 0 {
4025 self.note(format!(
4026 "sending {} in {}s (z cancels)",
4027 held.label, self.config.mail.undo_send
4028 ));
4029 self.hold_send(held);
4030 return None;
4031 }
4032 self.deliver(held);
4033 None
4034 }
4035
4036 fn confirm_attachment(&mut self) {
4039 self.attach_confirmed = true;
4040 }
4041
4042 fn hand_back(&mut self, draft: Compose) {
4044 self.draft = Some(draft);
4045 self.requests.push(Request::ShowDraft);
4046 }
4047
4048 pub fn draft(&self) -> Option<&Compose> {
4050 self.draft.as_ref()
4051 }
4052
4053 pub fn draft_mut(&mut self) -> Option<&mut Compose> {
4054 self.draft.as_mut()
4055 }
4056
4057 pub fn take_draft(&mut self) -> Option<Compose> {
4060 self.draft.take()
4061 }
4062
4063 pub fn set_draft(&mut self, draft: Compose) {
4070 if let Some((path, staged)) = self.staged.take()
4071 && self.config.mail.abort_unmodified.unwrap_or(true)
4072 && path == draft.path
4073 && std::fs::read_to_string(&draft.path).is_ok_and(|now| now == staged)
4074 {
4075 let _ = std::fs::remove_file(&draft.path);
4076 self.draft = None;
4077 self.error("aborted unmodified message");
4078 return;
4079 }
4080 self.draft = Some(draft);
4081 }
4082
4083 pub fn set_attach_field(&mut self, k: usize, input: &str, is_type: bool) {
4088 let value = input.trim().to_string();
4089 self.edit_draft_head(|head| {
4090 let mut seen = 0usize;
4091 head.lines()
4092 .map(|l| {
4093 let is_attach = l
4094 .split_once(':')
4095 .is_some_and(|(key, _)| key.trim().eq_ignore_ascii_case("attach"));
4096 if is_attach {
4097 let (_, mut atts) = compose::extract_attachments(l);
4100 if let Some(mut a) = atts.pop() {
4101 let idx = seen;
4102 seen += 1;
4103 if idx == k {
4104 let new = (!value.is_empty()).then(|| value.clone());
4105 if is_type {
4106 a.mime = new;
4107 } else {
4108 a.description = new;
4109 }
4110 return compose::attach_line(&a);
4111 }
4112 }
4113 }
4114 l.to_string()
4115 })
4116 .collect::<Vec<_>>()
4117 .join("\n")
4118 });
4119 }
4120
4121 pub fn attach_index(&mut self, sel: usize) -> Option<usize> {
4127 let fixed = 1 + usize::from(self.draft().is_some_and(|c| c.attach.is_some()));
4128 if sel < fixed {
4129 self.error("only Attach: files can be changed here");
4130 return None;
4131 }
4132 Some(sel - fixed)
4133 }
4134
4135 pub fn attachments(&self) -> Vec<compose::Attachment> {
4137 self.draft()
4138 .and_then(|c| draft_full(c).ok())
4139 .map(|full| compose::extract_attachments(&full).1)
4140 .unwrap_or_default()
4141 }
4142
4143 pub fn edit_attachment(&mut self, k: usize, f: impl Fn(&mut compose::Attachment)) {
4145 self.edit_draft_head(|head| {
4146 let mut seen = 0usize;
4147 head.lines()
4148 .map(|l| {
4149 let is_attach = l
4150 .split_once(':')
4151 .is_some_and(|(key, _)| key.trim().eq_ignore_ascii_case("attach"));
4152 if is_attach {
4153 let (_, mut atts) = compose::extract_attachments(l);
4154 if let Some(mut a) = atts.pop() {
4155 let idx = seen;
4156 seen += 1;
4157 if idx == k {
4158 f(&mut a);
4159 return compose::attach_line(&a);
4160 }
4161 }
4162 }
4163 l.to_string()
4164 })
4165 .collect::<Vec<_>>()
4166 .join("\n")
4167 });
4168 }
4169
4170 pub fn toggle_unlink(&mut self, sel: usize) {
4173 let Some(k) = self.attach_index(sel) else {
4174 return;
4175 };
4176 self.edit_attachment(k, |a| a.unlink = !a.unlink);
4177 let on = self.attachments().get(k).is_some_and(|a| a.unlink);
4178 self.note(if on {
4179 "the file will be deleted after sending"
4180 } else {
4181 "the file will be kept after sending"
4182 });
4183 }
4184
4185 pub fn toggle_disposition(&mut self, sel: usize) {
4187 let Some(k) = self.attach_index(sel) else {
4188 return;
4189 };
4190 self.edit_attachment(k, |a| a.inline = !a.inline);
4191 }
4192
4193 pub fn move_attachment(&mut self, sel: usize, up: bool) -> bool {
4196 let Some(k) = self.attach_index(sel) else {
4197 return false;
4198 };
4199 let n = self.attachments().len();
4200 let other = if up {
4201 k.checked_sub(1)
4202 } else {
4203 (k + 1 < n).then_some(k + 1)
4204 };
4205 let Some(other) = other else { return false };
4206 self.edit_draft_head(|head| {
4207 let mut lines: Vec<String> = head.lines().map(String::from).collect();
4208 let attach_rows: Vec<usize> = lines
4209 .iter()
4210 .enumerate()
4211 .filter(|(_, l)| {
4212 l.split_once(':')
4213 .is_some_and(|(key, _)| key.trim().eq_ignore_ascii_case("attach"))
4214 })
4215 .map(|(i, _)| i)
4216 .collect();
4217 if let (Some(&a), Some(&b)) = (attach_rows.get(k), attach_rows.get(other)) {
4218 lines.swap(a, b);
4219 }
4220 lines.join("\n")
4221 });
4222 true
4223 }
4224
4225 fn push_attach_line(&mut self, line: &str) -> std::io::Result<()> {
4227 let Some(c) = self.draft_mut() else {
4228 return Ok(());
4229 };
4230 match &mut c.hidden_head {
4231 Some(head) => {
4232 *head = format!("{}\n{line}", head.trim_end());
4233 Ok(())
4234 }
4235 None => std::fs::read_to_string(&c.path).and_then(|text| {
4236 let updated = match text.split_once("\n\n") {
4237 Some((head, body)) => format!("{head}\n{line}\n\n{body}"),
4238 None => format!("{}\n{line}\n", text.trim_end()),
4239 };
4240 std::fs::write(&c.path, updated)
4241 }),
4242 }
4243 }
4244
4245 pub fn attach_messages(&mut self) {
4251 if self.draft().is_none() {
4252 return;
4253 }
4254 let tagged = self.msgs.iter().any(|m| m.env.tagged);
4255 let paths = self.target_paths(tagged);
4256 let mut attached = 0usize;
4257 for path in paths {
4258 if remote::is_partial(&path) {
4259 self.error(format!(
4260 "{} is not fetched yet: open it first",
4261 path.file_name().and_then(|n| n.to_str()).unwrap_or("?")
4262 ));
4263 continue;
4264 }
4265 let mut a = compose::Attachment::of(path);
4266 a.mime = Some("message/rfc822".into());
4267 a.inline = true;
4268 let line = compose::attach_line(&a);
4269 match self.push_attach_line(&line) {
4270 Ok(()) => attached += 1,
4271 Err(err) => self.error(format!("cannot attach: {err}")),
4272 }
4273 }
4274 if attached > 0 {
4275 self.note(format!("attached {attached} message(s)"));
4276 }
4277 self.requests.push(Request::ShowDraft);
4278 }
4279
4280 pub fn new_mime(&mut self, path: &str, mime: &str) {
4284 if mime.is_empty() {
4285 self.requests.push(Request::ShowDraft);
4286 return;
4287 }
4288 if !mime.contains('/') || mime.starts_with('/') || mime.ends_with('/') {
4289 self.error("Content-Type is of the form base/sub");
4290 self.requests.push(Request::ShowDraft);
4291 return;
4292 }
4293 let file = expand_tilde(path);
4294 if !file.exists()
4295 && let Err(err) = std::fs::write(&file, b"")
4296 {
4297 self.error(format!("cannot create {}: {err}", file.display()));
4298 self.requests.push(Request::ShowDraft);
4299 return;
4300 }
4301 let mut a = compose::Attachment::of(file.clone());
4302 a.mime = Some(mime.to_string());
4303 let line = compose::attach_line(&a);
4304 if let Err(err) = self.push_attach_line(&line) {
4305 self.error(format!("cannot attach: {err}"));
4306 } else {
4307 self.requests.push(Request::EditFile(file));
4308 }
4309 self.requests.push(Request::ShowDraft);
4310 }
4311
4312 pub fn ispell_command(&self) -> Option<String> {
4315 let path = self.draft()?.path.display().to_string();
4316 let ispell = self.config.mail.ispell.as_deref().unwrap_or("ispell");
4317 Some(format!("{ispell} -x '{}'", path.replace('\'', "'\\''")))
4318 }
4319
4320 fn outgoing_text(&self, c: &Compose) -> Result<String> {
4323 let raw = draft_full(c).context("reading the draft")?;
4324 let (raw, files) = compose::extract_attachments(&raw);
4325 let host = maildir::hostname();
4326 let from = self
4327 .current_identity(&[])
4328 .from_line()
4329 .unwrap_or_else(|| default_from(&host));
4330 let msg_host = self
4331 .config
4332 .mail
4333 .hostname
4334 .clone()
4335 .filter(|h| !h.trim().is_empty())
4336 .unwrap_or(host);
4337 let text = compose::finalize_with(
4338 &raw,
4339 &from,
4340 &compose::make_message_id(&msg_host),
4341 &compose::rfc2822_now(),
4342 self.config.mail.user_agent.unwrap_or(false),
4343 )
4344 .map_err(|e| anyhow::anyhow!("{e}"))?;
4345 let original = match &c.attach {
4346 Some(path) => Some(std::fs::read(path).context("reading the original")?),
4347 None => None,
4348 };
4349 self.secure_message(c.security, text, &files, original.as_deref())
4350 }
4351
4352 pub fn write_draft_to(&mut self, mailbox: &str) {
4356 let Some(c) = self.draft() else { return };
4357 let text = match self.outgoing_text(c) {
4358 Ok(t) => t,
4359 Err(err) => {
4360 self.error(format!("cannot write the message: {err:#}"));
4361 return;
4362 }
4363 };
4364 let flags = maildir::Flags {
4365 seen: true,
4366 ..Default::default()
4367 };
4368 match remote::parse_spec(mailbox) {
4369 Some((account, folder)) => {
4370 let Some(imap) = self
4371 .imap
4372 .as_mut()
4373 .filter(|i| i.facts.account.name == account)
4374 else {
4375 self.error("write-fcc to IMAP needs a folder of the open account");
4376 return;
4377 };
4378 match imap.blocking(Job::Append {
4379 mailbox: Some(folder.to_string()),
4380 flags,
4381 body: text.into_bytes(),
4382 }) {
4383 Ok(_) => self.note(format!("Message written to {mailbox}.")),
4384 Err(err) => self.error(format!("write failed: {err:#}")),
4385 }
4386 }
4387 None => {
4388 let dir = expand_tilde(mailbox);
4389 if !dir.join("cur").is_dir() {
4390 self.error(format!("{mailbox} is not a maildir"));
4391 return;
4392 }
4393 match maildir::deliver(&dir, text.as_bytes(), flags) {
4394 Ok(_) => self.note(format!("Message written to {mailbox}.")),
4395 Err(err) => self.error(format!("write failed: {err:#}")),
4396 }
4397 }
4398 }
4399 }
4400
4401 pub fn detach(&mut self, sel: usize) {
4402 let fixed = 1 + usize::from(self.draft().is_some_and(|c| c.attach.is_some()));
4403 if sel < fixed {
4404 self.error("only Attach: files can be detached");
4405 return;
4406 }
4407 let k = sel - fixed;
4408 self.edit_draft_head(|head| {
4409 let mut seen = 0usize;
4410 head.lines()
4411 .filter(|l| {
4412 let is_attach = l
4413 .split_once(':')
4414 .is_some_and(|(key, _)| key.trim().eq_ignore_ascii_case("attach"));
4415 if is_attach {
4416 seen += 1;
4417 seen - 1 != k
4418 } else {
4419 true
4420 }
4421 })
4422 .collect::<Vec<_>>()
4423 .join("\n")
4424 });
4425 }
4426
4427 pub fn attach_file(&mut self, input: &str) {
4431 let input = input.trim();
4432 if !input.is_empty() {
4433 if !expand_tilde(input).is_file() {
4434 self.error(format!("{input} is not a file"));
4435 } else if let Some(c) = self.draft_mut() {
4436 let value = if input.contains(char::is_whitespace) && !input.starts_with('"') {
4439 format!("\"{input}\"")
4440 } else {
4441 input.to_string()
4442 };
4443 let result = match &mut c.hidden_head {
4444 Some(head) => {
4446 *head = format!("{}\nAttach: {value}", head.trim_end());
4447 Ok(())
4448 }
4449 None => std::fs::read_to_string(&c.path).and_then(|text| {
4450 let updated = match text.split_once("\n\n") {
4451 Some((head, body)) => format!("{head}\nAttach: {value}\n\n{body}"),
4452 None => format!("{}\nAttach: {value}\n", text.trim_end()),
4453 };
4454 std::fs::write(&c.path, updated)
4455 }),
4456 };
4457 if let Err(err) = result {
4458 self.error(format!("cannot attach: {err}"));
4459 }
4460 }
4461 }
4462 self.requests.push(Request::ShowDraft);
4463 }
4464
4465 pub fn error(&mut self, msg: impl Into<String>) {
4466 self.notify(Notice::Error(msg.into()));
4467 }
4468
4469 pub fn note(&mut self, msg: impl Into<String>) {
4471 self.notify(Notice::Info(msg.into()));
4472 }
4473
4474 fn notify(&mut self, notice: Notice) {
4479 if let Notice::Error(text) = ¬ice {
4480 let keep = self.config.ui.error_history;
4481 self.error_history.push_back(text.clone());
4482 while self.error_history.len() > keep {
4483 self.error_history.pop_front();
4484 }
4485 }
4486 if (notice.is_error() && self.config.ui.beep)
4489 || (notice.is_new_mail() && self.config.ui.beep_new)
4490 {
4491 use std::io::Write as _;
4492 let mut out = std::io::stdout();
4493 let _ = out.write_all(b"\x07");
4494 let _ = out.flush();
4495 }
4496 self.notices.notice(notice);
4497 }
4498
4499 pub fn notice(&self) -> Option<&Notice> {
4502 self.notices.latest()
4503 }
4504}
4505
4506fn subject_key(subject: &str) -> String {
4508 let mut key = subject.trim().to_lowercase();
4509 loop {
4510 let stripped = key
4511 .strip_prefix("re:")
4512 .or_else(|| key.strip_prefix("fwd:"))
4513 .or_else(|| key.strip_prefix("fw:"))
4514 .map(|rest| rest.trim_start().to_string());
4515 match stripped {
4516 Some(next) => key = next,
4517 None => break,
4518 }
4519 }
4520 key
4521}
4522
4523pub fn parse_sort(spec: &str) -> Option<(SortKey, bool)> {
4525 let (rev, name) = match spec.strip_prefix("reverse-") {
4526 Some(rest) => (true, rest),
4527 None => (false, spec),
4528 };
4529 let key = match name {
4530 "date" | "date-sent" | "date-received" => SortKey::Date,
4531 "from" => SortKey::From,
4532 "subject" => SortKey::Subject,
4533 "size" => SortKey::Size,
4534 "threads" => SortKey::Threads,
4535 "label" => SortKey::Label,
4536 "to" => SortKey::To,
4537 "unsorted" | "mailbox-order" => SortKey::Unsorted,
4538 _ => return None,
4539 };
4540 Some((key, rev && key != SortKey::Threads))
4542}
4543
4544impl pattern::EnvSource for Session {
4545 fn envelope(&self, i: usize) -> Option<&Envelope> {
4546 self.msgs.get(i).map(|m| &m.env)
4547 }
4548}
4549
4550fn dir_mtimes(dir: &Path) -> (Option<SystemTime>, Option<SystemTime>) {
4551 let mtime = |p: PathBuf| p.metadata().and_then(|m| m.modified()).ok();
4552 (mtime(dir.join("new")), mtime(dir.join("cur")))
4553}
4554
4555pub fn account_password(account: &Account) -> Result<String> {
4558 if !matches!(account.auth_kind()?, rmut_core::config::AuthKind::Password) {
4559 return account.secret();
4560 }
4561 static CACHE: Mutex<Option<HashMap<String, String>>> = Mutex::new(None);
4562 let mut cache = CACHE.lock().unwrap();
4563 let map = cache.get_or_insert_with(HashMap::new);
4564 if let Some(password) = map.get(&account.name) {
4565 return Ok(password.clone());
4566 }
4567 let password = account.password()?;
4568 map.insert(account.name.clone(), password.clone());
4569 Ok(password)
4570}
4571
4572pub fn wrap_order(n: usize, sel: usize, forward: bool) -> Vec<(usize, bool)> {
4577 (1..=n)
4578 .map(|step| {
4579 if forward {
4580 ((sel + step) % n, sel + step >= n)
4581 } else {
4582 ((sel + n - (step % n)) % n, step > sel)
4583 }
4584 })
4585 .collect()
4586}
4587
4588pub fn sort_browser(dirs: &mut [(String, usize)], how: Option<&str>) {
4596 let how = how.unwrap_or("alpha");
4597 let (reverse, key) = match how.strip_prefix("reverse-") {
4598 Some(key) => (true, key),
4599 None => (false, how),
4600 };
4601 match key {
4602 "unsorted" => {}
4603 "count" | "unread" => dirs.sort_by_key(|(_, count)| *count),
4604 "date" => dirs.sort_by_cached_key(|(spec, _)| {
4605 if spec.starts_with("imap:") {
4606 return std::time::SystemTime::UNIX_EPOCH;
4607 }
4608 let dir = expand_tilde(spec);
4609 ["new", "cur"]
4610 .iter()
4611 .filter_map(|sub| std::fs::metadata(dir.join(sub)).ok())
4612 .filter_map(|m| m.modified().ok())
4613 .max()
4614 .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
4615 }),
4616 _ => dirs.sort_by(|a, b| a.0.cmp(&b.0)),
4617 }
4618 if reverse {
4619 dirs.reverse();
4620 }
4621}
4622
4623pub fn expand_tilde(input: &str) -> PathBuf {
4624 if let Some(rest) = input.strip_prefix("~/")
4625 && let Ok(home) = std::env::var("HOME")
4626 {
4627 return Path::new(&home).join(rest);
4628 }
4629 PathBuf::from(input)
4630}
4631
4632pub fn send_via_smtp(account: &Account, text: &str) -> Result<()> {
4633 let from = compose::from_address(text).context("cannot parse the From address")?;
4634 let (rcpts, text) = compose::smtp_envelope(text)?;
4635 anyhow::ensure!(!rcpts.is_empty(), "no recipient addresses");
4636 let password = account_password(account)?;
4637 smtp::send(account, &password, &from, &rcpts, text.as_bytes())
4638}
4639
4640pub fn pipe_to(command: &str, bytes: &[u8]) -> Result<()> {
4642 let mut child = Command::new("sh")
4643 .arg("-c")
4644 .arg(command)
4645 .stdin(Stdio::piped())
4646 .stdout(Stdio::null())
4647 .stderr(Stdio::null())
4648 .spawn()
4649 .with_context(|| format!("running {command}"))?;
4650 {
4654 let mut stdin = child.stdin.take().context("no stdin on the child")?;
4655 if let Err(err) = stdin.write_all(bytes)
4656 && err.kind() != std::io::ErrorKind::BrokenPipe
4657 {
4658 return Err(err.into());
4659 }
4660 }
4661 let status = child.wait()?;
4662 anyhow::ensure!(status.success(), "{command} exited with {status}");
4663 Ok(())
4664}
4665
4666pub fn run_sendmail(
4669 bytes: &[u8],
4670 configured: Option<&str>,
4671 rcpts: Option<&[String]>,
4672) -> Result<()> {
4673 let command = std::env::var("RMUT_SENDMAIL")
4674 .ok()
4675 .or_else(|| configured.map(String::from));
4676 let (prog, mut args) = match command {
4677 Some(v) => {
4678 let mut it = v.split_whitespace().map(String::from);
4679 let p = it.next().context("sendmail command is empty")?;
4680 (p, it.collect::<Vec<_>>())
4681 }
4682 None => {
4683 let p = if Path::new("/usr/sbin/sendmail").exists() {
4684 "/usr/sbin/sendmail".to_string()
4685 } else {
4686 "sendmail".to_string()
4687 };
4688 (p, Vec::new())
4689 }
4690 };
4691 match rcpts {
4692 Some(rcpts) => {
4693 args.push("-oi".into());
4694 args.extend(rcpts.iter().cloned());
4695 }
4696 None => args.extend(["-t".into(), "-oi".into()]),
4697 }
4698 let mut child = Command::new(&prog)
4699 .args(&args)
4700 .stdin(Stdio::piped())
4701 .stdout(Stdio::null())
4702 .stderr(Stdio::null())
4703 .spawn()
4704 .with_context(|| format!("running {prog}"))?;
4705 child
4706 .stdin
4707 .take()
4708 .context("no stdin on sendmail child")?
4709 .write_all(bytes)?;
4710 let status = child.wait()?;
4711 anyhow::ensure!(status.success(), "{prog} exited with {status}");
4712 Ok(())
4713}
4714
4715pub fn default_from(hostname: &str) -> String {
4716 if let Ok(email) = std::env::var("EMAIL") {
4717 return email;
4718 }
4719 let user = std::env::var("USER").unwrap_or_else(|_| "user".into());
4720 format!("{user}@{hostname}")
4721}
4722
4723const DEFAULT_ATTACH_KEYWORD: &str = r"\b(attach|attached|attaching|attachment|attachments)\b";
4726
4727fn attach_re_from_config(config: &Config, warnings: &mut Vec<String>) -> regex_lite::Regex {
4730 let spec = config
4731 .mail
4732 .attach_keyword
4733 .clone()
4734 .unwrap_or_else(|| DEFAULT_ATTACH_KEYWORD.to_string());
4735 match regex_lite::Regex::new(&format!("(?i){spec}")) {
4736 Ok(re) => re,
4737 Err(err) => {
4738 warnings.push(format!("bad attach_keyword {spec:?}: {err}"));
4739 default_attach_re()
4740 }
4741 }
4742}
4743
4744fn default_attach_re() -> regex_lite::Regex {
4745 regex_lite::Regex::new(&format!("(?i){DEFAULT_ATTACH_KEYWORD}")).expect("the default compiles")
4746}
4747
4748fn reply_re_from_config(config: &Config, warnings: &mut Vec<String>) -> regex_lite::Regex {
4751 match &config.mail.reply_regexp {
4752 Some(spec) => match compose::reply_regexp(spec) {
4753 Ok(re) => re,
4754 Err(err) => {
4755 warnings.push(format!("bad reply_regexp {spec:?}: {err}"));
4756 compose::default_reply_regexp()
4757 }
4758 },
4759 None => compose::default_reply_regexp(),
4760 }
4761}
4762
4763fn quote_re_from_config(config: &Config, warnings: &mut Vec<String>) -> regex_lite::Regex {
4766 match &config.pager.quote_regexp {
4767 Some(spec) => match regex_lite::Regex::new(spec) {
4768 Ok(re) => re,
4769 Err(err) => {
4770 warnings.push(format!("bad quote_regexp {spec:?}: {err}"));
4771 default_quote_re()
4772 }
4773 },
4774 None => default_quote_re(),
4775 }
4776}
4777
4778fn display_from_config(config: &Config) -> message::Display {
4781 let mut rules = message::HeaderRules::default();
4785 let clean = |list: &Vec<String>| {
4786 list.iter()
4787 .map(|n| n.trim_end_matches(':').to_lowercase())
4788 .collect::<Vec<_>>()
4789 };
4790 if let Some(list) = &config.pager.ignore {
4791 rules.ignore = clean(list);
4792 }
4793 if let Some(list) = &config.pager.unignore {
4794 rules.unignore = clean(list);
4795 }
4796 if let Some(list) = &config.pager.hdr_order {
4797 rules.order = clean(list);
4798 }
4799 let mut filters: HashMap<String, String> = HashMap::new();
4804 let mut mailcap: Option<Vec<rmut_core::mailcap::Entry>> = None;
4805 for (mimetype, command) in &config.filters {
4806 let mimetype = mimetype.to_lowercase();
4807 if !command.trim().is_empty() {
4808 filters.insert(mimetype, command.clone());
4809 continue;
4810 }
4811 let entries = mailcap.get_or_insert_with(rmut_core::mailcap::load);
4812 if let Some(command) = rmut_core::mailcap::command_for(entries, &mimetype) {
4813 filters.insert(mimetype, command);
4814 }
4815 }
4816 message::Display {
4817 filters,
4818 rules,
4819 reflow: config.pager.reflow_text.unwrap_or(true),
4820 alternative_order: config
4821 .pager
4822 .alternative_order
4823 .iter()
4824 .map(|t| t.to_lowercase())
4825 .collect(),
4826 }
4827}
4828
4829fn compile_hooks<'a>(
4832 what: &str,
4833 entries: impl Iterator<Item = (&'a String, &'a String)>,
4834 warnings: &mut Vec<String>,
4835) -> Vec<Hook> {
4836 let mut out = Vec::new();
4837 for (spec, value) in entries {
4838 if value.trim().is_empty() {
4839 warnings.push(format!("{what} {spec:?} has nothing to do"));
4840 continue;
4841 }
4842 match pattern::parse(spec) {
4843 Ok(patterns) => out.push(Hook {
4844 patterns,
4845 value: value.clone(),
4846 }),
4847 Err(err) => warnings.push(format!("bad {what} pattern {spec:?}: {err}")),
4848 }
4849 }
4850 out
4851}
4852
4853pub fn header_value(head: &str, name: &str) -> Option<String> {
4855 head.lines().find_map(|l| {
4856 let (k, v) = l.split_once(':')?;
4857 k.trim()
4858 .eq_ignore_ascii_case(name)
4859 .then(|| v.trim().to_string())
4860 })
4861}
4862
4863pub fn draft_full(c: &Compose) -> std::io::Result<String> {
4866 let text = std::fs::read_to_string(&c.path)?;
4867 Ok(match &c.hidden_head {
4868 Some(head) => format!("{}\n\n{}", head.trim_end(), text),
4869 None => text,
4870 })
4871}
4872
4873pub fn default_quote_re() -> regex_lite::Regex {
4875 regex_lite::Regex::new(r"^([ \t]*[|>:}#])+").expect("default quote_regexp compiles")
4876}
4877
4878pub fn write_draft(text: &str) -> Result<PathBuf> {
4879 use std::sync::atomic::{AtomicUsize, Ordering};
4880 static COUNTER: AtomicUsize = AtomicUsize::new(0);
4881 let path = std::env::temp_dir().join(format!(
4882 "rmut-draft-{}-{}.eml",
4883 std::process::id(),
4884 COUNTER.fetch_add(1, Ordering::Relaxed),
4885 ));
4886 std::fs::write(&path, text).with_context(|| format!("writing {}", path.display()))?;
4887 Ok(path)
4888}