Skip to main content

rmut_session/
lib.rs

1//! rmut's mail session: an open mailbox and the operations over it.
2//!
3//! Everything here works without a screen. A front end owns the menus,
4//! the keys and the drawing, and drives a [`Session`] for the rest:
5//! what is in the mailbox, what is selected, what a mark or a sync
6//! did. Outcomes leave as [`Notice`]s through the sink the front end
7//! installs, so an operation never has to know how it will be shown.
8
9use std::collections::{HashMap, HashSet};
10use std::io::Write as _;
11use std::mem;
12use std::path::{Path, PathBuf};
13use std::process::{Command, Stdio};
14use std::sync::mpsc::{Receiver, TryRecvError};
15use std::sync::{Arc, Mutex};
16use std::time::{Duration, Instant, SystemTime};
17
18use anyhow::{Context, Result};
19use rmut_core::config::{Account, Config};
20use rmut_core::message::Envelope;
21use rmut_core::notice::{Notice, NoticeSink};
22use rmut_core::pattern::{self, Pattern};
23use rmut_core::remote::{self, Remote};
24use rmut_core::{alias, compose, hdrcache, maildir, mbox, message, pgp, smtp, thread};
25
26mod ask;
27mod commands;
28mod drafts;
29mod function;
30#[cfg(test)]
31mod tests;
32mod worker;
33
34pub use ask::{Answer, Ask, AskKind, Key, PatternOp, Request, Wants};
35pub use commands::CommandRun;
36pub use function::{FrontOp, Function, Outcome, PageSpot, SidebarOp};
37pub use worker::{Done, Facts, Imap, Job, Manage};
38
39/// How many undo steps to keep, and how many message snapshots in
40/// total: a pattern delete over a huge mailbox is one step but very
41/// many marks, so both are bounded and the oldest steps go first.
42const UNDO_MAX_STEPS: usize = 32;
43const UNDO_MAX_MARKS: usize = 100_000;
44
45pub struct Msg {
46    pub env: message::Envelope,
47    /// Flags changed since the last sync (rename pending).
48    pub dirty: bool,
49    /// mutt's purge-message: deleted without the detour through
50    /// $trash. Meaningless unless deleted; undelete clears it.
51    pub purge: bool,
52}
53
54impl Msg {
55    pub fn pending(&self) -> bool {
56        self.dirty || self.env.file.flags.deleted
57    }
58}
59
60/// One message's state before a step touched it. The path is the
61/// identity: it only changes when the mailbox is written, and a write
62/// drops the whole stack.
63#[derive(Clone)]
64pub struct MsgMark {
65    pub path: PathBuf,
66    pub flags: maildir::Flags,
67    pub is_new: bool,
68    pub tagged: bool,
69    pub dirty: bool,
70}
71
72/// One undoable step: what it was, the messages as they stood before
73/// it, and where the cursor was.
74pub struct UndoStep {
75    pub what: String,
76    pub marks: Vec<MsgMark>,
77    /// The message the cursor was on, by path: a resort or a limit
78    /// can move it, so the position alone would not find it again.
79    pub sel: Option<PathBuf>,
80    /// Files the step created (a save or copy's delivered message),
81    /// removed again when it is undone.
82    pub created: Vec<PathBuf>,
83    /// Something the undo cannot take back, said out loud when it
84    /// runs (a copy that went to an IMAP folder).
85    pub note: Option<String>,
86    /// Messages the step rewrote on disk (break-thread, link-threads),
87    /// with the bytes they held before, put back when it is undone.
88    pub rewritten: Vec<(PathBuf, Vec<u8>)>,
89}
90
91#[derive(Clone, Copy, PartialEq, Eq, Debug)]
92pub enum SortKey {
93    Date,
94    From,
95    Subject,
96    Size,
97    Threads,
98    /// mutt's sort=label: by X-Label, unlabelled last.
99    Label,
100    /// mutt's sort=to: by the first To address.
101    To,
102    /// mutt's sort=mailbox-order: unsorted, the order on disk (by
103    /// file name, which is stable across a rescan).
104    Unsorted,
105}
106
107impl SortKey {
108    pub fn name(self) -> &'static str {
109        match self {
110            SortKey::Date => "date",
111            SortKey::From => "from",
112            SortKey::Subject => "subject",
113            SortKey::Size => "size",
114            SortKey::Threads => "threads",
115            SortKey::Label => "label",
116            SortKey::To => "to",
117            SortKey::Unsorted => "unsorted",
118        }
119    }
120}
121
122#[derive(Clone, Copy, PartialEq, Eq)]
123pub enum ThreadOp {
124    Delete,
125    Undelete,
126    Tag,
127    /// mutt's read-thread / read-subthread.
128    Read,
129}
130
131/// Whether a save target is a mailbox that already holds mail, for
132/// mutt's $confirmappend. An `imap:` spec is one; a local path is one
133/// when it looks like a maildir.
134fn mailbox_exists(spec: &str) -> bool {
135    match remote::parse_spec(spec) {
136        Some(_) => true,
137        None => expand_tilde(spec).join("cur").is_dir(),
138    }
139}
140
141/// A compiled hook: the pattern its message must match, and what the
142/// hook carries (an enter-command line, or an Fcc mailbox).
143pub struct Hook {
144    patterns: Vec<Pattern>,
145    /// The command line to run, or the mailbox to file the copy in.
146    pub value: String,
147}
148
149#[derive(Clone, Copy, PartialEq, Eq)]
150pub enum ComposeKind {
151    New,
152    Reply,
153    GroupReply,
154    /// mutt's list-reply: the mailing list is the only recipient.
155    ListReply,
156    Forward,
157}
158
159/// Snapshot of the message being replied to / forwarded, taken when the
160/// compose flow starts.
161pub struct ComposeBase {
162    pub path: PathBuf,
163    pub reply_to: String,
164    /// The From header as written, the reply target when the
165    /// Reply-To question is answered no.
166    pub from_hdr: String,
167    /// The message has a Reply-To differing from From, worth the
168    /// mutt $reply_to (ask-yes) question.
169    pub has_reply_to: bool,
170    pub orig_to: String,
171    pub orig_cc: String,
172    /// List-Post's posting address, when the list published one.
173    pub list_post: Option<String>,
174    /// Mail-Followup-To as the sender wrote it, honored by a group
175    /// reply (mutt does the same).
176    pub followup_to: String,
177    /// Bare author address, for the forward subject's %a.
178    pub from_addr: String,
179    pub from_display: String,
180    pub subject: String,
181    pub date: i64,
182    pub msg_id: Option<String>,
183    pub references: Vec<String>,
184}
185
186pub struct ComposeSetup {
187    pub kind: ComposeKind,
188    pub base: Option<ComposeBase>,
189    pub to: Option<String>,
190    /// mutt's $askcc / $askbcc, once answered.
191    pub cc: Option<String>,
192    pub bcc: Option<String>,
193    /// What the Subject prompt will offer, worked out when To was
194    /// answered and parked while the copies are asked about.
195    pub subject_prefill: Option<String>,
196    /// Parked here while the include-original question is up.
197    pub subject: Option<String>,
198    /// mime_forward = "ask": the question's answer, once given.
199    pub fwd_attach: Option<bool>,
200}
201
202/// PGP treatment for an outgoing draft, chosen at the send prompt.
203#[derive(Clone, Copy, PartialEq, Eq, Debug)]
204pub enum Security {
205    None,
206    Sign,
207    Encrypt,
208    Both,
209}
210
211impl Security {
212    pub fn label(self) -> &'static str {
213        match self {
214            Security::None => "",
215            Security::Sign => "sign",
216            Security::Encrypt => "encrypt",
217            Security::Both => "sign+encrypt",
218        }
219    }
220}
221
222/// A draft file going through editor → send/postpone/discard.
223pub struct Compose {
224    pub path: PathBuf,
225    /// Postponed original to delete once the message is sent.
226    pub recall_source: Option<PathBuf>,
227    pub security: Security,
228    /// Original message to attach as message/rfc822 (forward =
229    /// "attach", mutt's mime_forward).
230    pub attach: Option<PathBuf>,
231    /// Header block withheld from the editor (edit_headers = false);
232    /// draft_full puts it back for send/postpone/attachments.
233    pub hidden_head: Option<String>,
234    /// Fcc chosen in the compose menu (`f`): None = the default sent
235    /// copy, Some("") = keep no copy, Some(path) = that maildir.
236    pub fcc: Option<String>,
237}
238
239/// A message that has been sent but is waiting out $undo_send before
240/// it goes anywhere. The finalized text is ready to transmit; the
241/// draft it came from is kept whole, so cancelling puts the compose
242/// menu back exactly as it was.
243pub struct Held {
244    pub state: Compose,
245    pub text: String,
246    /// The Fcc target as it was decided at send time (menu, then
247    /// fcc-hook); None means the default sent copy.
248    pub fcc: Option<String>,
249    /// What the status line calls it: the subject, or the recipients.
250    pub label: String,
251    pub due: Instant,
252}
253
254/// An operation that has asked the connection for something and is
255/// waiting to be carried on. The front end keeps drawing meanwhile,
256/// and calls [`Session::poll_network`] until the answer lands.
257/// How a message is marked for deletion: mutt's $flag_safe and
258/// $delete_untag, read once per operation.
259#[derive(Clone, Copy)]
260pub struct DeleteRules {
261    pub flag_safe: bool,
262    pub untag: bool,
263}
264
265impl DeleteRules {
266    /// Mark `m` deleted the way mutt_set_flag(MUTT_DELETE) does:
267    /// refused for a flagged message under flag_safe, and the tag
268    /// comes off under delete_untag. Whether the message changed.
269    pub fn mark(self, m: &mut Msg) -> bool {
270        if self.flag_safe && m.env.file.flags.flagged {
271            return false;
272        }
273        m.env.file.flags.deleted = true;
274        if self.untag {
275            m.env.tagged = false;
276        }
277        true
278    }
279}
280
281enum Pending {
282    /// The poll tick's look at the server.
283    CheckNew,
284    /// A `$` sync: the server has taken the flag changes and the
285    /// purge, and the local half follows.
286    Sync { purge: bool },
287    /// The bodies an operation needed are here: run it again, and
288    /// this time it finds everything it wants on disk.
289    Again(Again),
290    /// The unread counts of these mailboxes, in this order.
291    Counts(Vec<String>),
292    /// Anything else: the answer goes to `then`. With `hold` the user
293    /// is waiting on it, so the keys typed meanwhile wait too.
294    Then { hold: bool, then: Then },
295}
296
297impl Pending {
298    fn holds(&self) -> bool {
299        matches!(self, Pending::Then { hold: true, .. })
300    }
301}
302
303/// What a job's answer is handed to, for an operation that is one of
304/// a kind rather than a whole [`Again`].
305type Then = Box<dyn FnOnce(&mut Session, Result<Done>) + Send>;
306
307/// Work waiting for the connection to come free, oldest first.
308enum Deferred {
309    /// An operation that wanted bodies: it runs again from the top.
310    Again(Again),
311    /// A job, sent as it stands.
312    Job(Job, Pending),
313}
314
315impl Deferred {
316    fn holds(&self) -> bool {
317        matches!(self, Deferred::Job(_, pending) if pending.holds())
318    }
319
320    /// Whether a Ctrl+G leaves it be: a copy kept in the background
321    /// (an Fcc) is nobody's wait, so giving up on a wait spares it.
322    fn outlives_abort(&self) -> bool {
323        matches!(self, Deferred::Job(_, Pending::Then { hold: false, .. }))
324    }
325}
326
327/// A mailbox opening on a thread of its own: another account, or the
328/// same one after its connection refused the switch.
329struct Opening {
330    spec: String,
331    read_only: bool,
332    answer: Receiver<Result<(Session, Vec<String>)>>,
333    progress: Arc<Mutex<Option<String>>>,
334}
335
336/// What a save or copy did, for the half that reports it.
337struct Copied {
338    /// The messages that went, by index.
339    copied: Vec<usize>,
340    errors: Vec<String>,
341    /// Files delivered locally, which undo removes.
342    created: Vec<PathBuf>,
343    /// Where they went, as the report names it.
344    target: String,
345    /// What undo has to say about what it cannot take back.
346    note: Option<String>,
347    delete: bool,
348    tagged: bool,
349}
350
351/// An operation that asked for message bodies before it could run.
352/// It carries what it was told, since the front end has moved on.
353#[derive(Clone)]
354enum Again {
355    /// Open the selected message.
356    View,
357    Copy {
358        input: String,
359        delete: bool,
360        tagged: bool,
361        decode: bool,
362    },
363    Pipe {
364        command: String,
365        tagged: bool,
366    },
367    Print {
368        tagged: bool,
369    },
370    Bounce {
371        to: String,
372        tagged: bool,
373    },
374}
375
376/// One open mailbox and everything rmut knows about it.
377pub struct Session {
378    /// The maildir on disk: the mailbox itself, or the cache mirror of
379    /// an IMAP folder or an mbox file.
380    pub dir: PathBuf,
381    /// What this mailbox is called: the path for local maildirs, the
382    /// `imap:account/folder` spec for remote ones.
383    pub title: String,
384    /// Set when `dir` is the cache maildir of an IMAP folder: the
385    /// connection, on a thread of its own, and the facts about the
386    /// folder that need no asking.
387    imap: Option<Imap>,
388    /// Set when `dir` mirrors an mbox file; sync writes back into it.
389    pub mbox: Option<mbox::Mbox>,
390    pub msgs: Vec<Msg>,
391    /// Indices into `msgs` after applying limit and thread folding.
392    pub visible: Vec<usize>,
393    /// Selection, as an index into `visible`.
394    pub sel: usize,
395    pub sort: SortKey,
396    pub sort_rev: bool,
397    pub limit: Option<(String, Vec<Pattern>)>,
398    pub last_search: Option<Vec<Pattern>>,
399    /// Which way the last index search went, so `n` repeats in the
400    /// same direction (mutt's search / search-reverse pair).
401    pub search_rev: bool,
402    /// Per-message thread depth/root (aligned with `msgs`; identity
403    /// when not sorted by threads).
404    pub thread_depth: Vec<usize>,
405    pub thread_root: Vec<usize>,
406    /// Which messages the subject fallback placed rather than their
407    /// own References (mutt's fake_thread): the index stars them.
408    pub thread_pseudo: Vec<bool>,
409    /// The thread as a tree, from the depth-first layout: each
410    /// message's parent and children, and every root's members. For
411    /// the `~(`, `~<`, `~>` patterns, parent-message, and read-thread.
412    pub thread_parent: Vec<Option<usize>>,
413    pub thread_children: Vec<Vec<usize>>,
414    pub thread_members: Vec<Vec<usize>>,
415    /// Paths of collapsed thread roots.
416    pub collapsed: HashSet<PathBuf>,
417    /// Undo stack, oldest first: delete/flag/tag/read marks and the
418    /// copies a save made, back to the state before each step.
419    undo: Vec<UndoStep>,
420    pub config: Config,
421    /// My own addresses (identity, accounts, $EMAIL), lowercase; the
422    /// exact half of `me()`, which adds `alternates` on top.
423    pub my_addresses: Vec<String>,
424    /// Compiled `mail.lists` + `mail.subscribed`, for `~l`, the `L`
425    /// list-reply target, and Mail-Followup-To.
426    pub lists: Vec<pattern::Matcher>,
427    /// The subscribed half on its own: only it drops my address from
428    /// a Mail-Followup-To.
429    pub subscribed: Vec<pattern::Matcher>,
430    /// Compiled `mail.alternates`: my other addresses, joined to `me`
431    /// wherever rmut asks whether an address is mine.
432    pub alternates: Vec<pattern::Matcher>,
433    /// Server-side `~b` results: term -> matching UIDs, filled when a
434    /// limit/search pattern with body terms is submitted on IMAP.
435    body_hits: HashMap<String, HashSet<u32>>,
436    /// `~b` terms the server would not search: read locally.
437    body_local: HashSet<String>,
438    /// The open account's folders as the server last listed them,
439    /// for the browser and mailbox completion.
440    server_folders: Option<Vec<(String, usize)>>,
441    /// A LIST is on its way, so asking again waits for it.
442    listing: bool,
443    /// Background IDLE watcher for the open IMAP folder.
444    pub idle: Option<remote::IdleWatch>,
445    /// Background header mirror for the tail of a huge IMAP folder.
446    backfill: Option<remote::Backfill>,
447    /// New-mail counts of the other configured mailboxes at the last
448    /// poll, to notice growth (mutt's `mailboxes` awareness).
449    mailbox_new: HashMap<String, usize>,
450    /// mutt's error history: the last $error_history complaints, for
451    /// the error-history screen.
452    error_history: std::collections::VecDeque<String>,
453    /// The mailboxes announced under $mail_check_recent = false, so
454    /// each is said once while it holds new mail.
455    mailbox_announced: HashSet<String>,
456    /// Unread counts of the open account's folders, as of the last
457    /// time the connection was free to count them.
458    unseen: HashMap<String, usize>,
459    /// `rmut -R`: nothing is ever written, not even read marks.
460    pub read_only: bool,
461    /// `-R`: the whole session is read-only, so a mailbox switch
462    /// cannot quietly make it writable again. Alt+c sets `read_only`
463    /// for one mailbox without touching this.
464    pub read_only_session: bool,
465    /// mtimes of new/ and cur/ used for new-mail detection.
466    pub(crate) dir_mtimes: (Option<SystemTime>, Option<SystemTime>),
467    /// Compiled hook tables (patterns plus the line or mailbox each
468    /// carries). The front end still runs a hook's command line,
469    /// because one can rebind a key; which hooks match a message is
470    /// the session's answer to give.
471    pub message_hooks: Vec<Hook>,
472    pub reply_hooks: Vec<Hook>,
473    fcc_hooks: Vec<Hook>,
474    crypt_hooks: Vec<(pattern::Matcher, String)>,
475    /// ignore/unignore/hdr_order and the [filters] table: how a
476    /// message's parts turn into the text a reader sees.
477    pub display: message::Display,
478    /// Messages sent but still inside their $undo_send window, oldest
479    /// first. They go out when the timer runs out or rmut leaves.
480    outbox: Vec<Held>,
481    /// Compiled $quote_regexp classifying quoted body lines: a pager
482    /// colours by it, and the attachment reminder skips them.
483    pub quote_re: regex_lite::Regex,
484    /// Compiled $reply_regexp, for the subject a reply carries.
485    pub reply_re: regex_lite::Regex,
486    /// Compiled $abort_noattach_regex, for the attachment reminder.
487    attach_re: regex_lite::Regex,
488    /// The operation waiting for the connection to come back, if
489    /// any: at most one, because there is one connection.
490    pending: Option<Pending>,
491    /// Operations that wanted the connection while something else
492    /// held it. They go next, ahead of anything the tick would start,
493    /// without the front end blocking on the tick.
494    deferred: std::collections::VecDeque<Deferred>,
495    /// A mailbox being opened in the background; this session stays
496    /// on screen until it is ready.
497    opening: Option<Opening>,
498    /// Whether the message line is showing a progress line, so it can
499    /// be taken back down when the job it belongs to is done.
500    progress_noted: bool,
501    /// Whether the message line holds something the user was told
502    /// and has not moved past: a progress line does not cover it.
503    /// Kept here, since a sink need not say what it shows.
504    spoken: bool,
505    /// Set by Ctrl+G: the answer that comes back is the abort, not
506    /// something to complain about.
507    aborted: bool,
508    /// What Enter means at the quit question, from $quit's ask-yes or
509    /// ask-no.
510    quit_default: bool,
511    /// The compose flow in progress: what is being answered about
512    /// the draft that has not been written yet.
513    setup: Option<ComposeSetup>,
514    /// The draft in hand: written, and being looked at. A front end
515    /// shows it however it shows drafts; the session owns what it
516    /// says.
517    draft: Option<Compose>,
518    /// The draft file as it was staged, for mutt's $abort_unmodified:
519    /// if the first editor pass hands back exactly this, there is no
520    /// message to send. Cleared as soon as it has been compared.
521    staged: Option<(PathBuf, String)>,
522    /// The attachment reminder has been answered for this draft: the
523    /// next send goes through without asking again.
524    attach_confirmed: bool,
525    /// The config as it was before the active message-hooks changed
526    /// it, so leaving the message puts every setting back.
527    hook_base: Option<Box<Config>>,
528    /// Which message-hooks are in force right now, by index; a change
529    /// here is what triggers restore-and-reapply.
530    active_message_hooks: Vec<usize>,
531    /// What the session wants the front end to do, oldest first.
532    requests: Vec<Request>,
533    /// Where outcomes go. Nothing is kept until a front end installs
534    /// a sink, so a session used as a library is silent by default.
535    notices: Box<dyn NoticeSink>,
536}
537
538/// The sink a session starts with: nothing said, nothing kept.
539struct Silence;
540
541impl NoticeSink for Silence {
542    fn notice(&mut self, _notice: Notice) {}
543
544    fn latest(&self) -> Option<&Notice> {
545        None
546    }
547
548    fn clear(&mut self) {}
549}
550
551impl Session {
552    /// Open a maildir.
553    ///
554    /// Warnings come back rather than going out as notices: at this
555    /// point no front end has installed a sink, and they are its to
556    /// show together with whatever its own setup had to say.
557    pub fn open(dir: &Path, config: Config) -> Result<(Session, Vec<String>)> {
558        // The header cache spares re-parsing every message on open.
559        let (envelopes, skipped) = hdrcache::load_envelopes(dir)?;
560        let mut msgs: Vec<Msg> = envelopes
561            .into_iter()
562            .map(|env| Msg {
563                env,
564                dirty: false,
565                purge: false,
566            })
567            .collect();
568        // Mutt's default sort: date, oldest first.
569        msgs.sort_by_key(|m| m.env.date);
570        let visible: Vec<usize> = (0..msgs.len()).collect();
571        // Like mutt: start on the first new message, else the last.
572        let sel = msgs
573            .iter()
574            .position(|m| m.env.file.is_new)
575            .unwrap_or(visible.len().saturating_sub(1));
576        let mut warnings = Vec::new();
577        if skipped > 0 {
578            warnings.push(format!("{skipped} unreadable message(s) skipped"));
579        }
580        let count = msgs.len();
581        let mut me: Vec<String> = config
582            .identity
583            .email
584            .iter()
585            .chain(config.accounts.iter().map(|a| &a.user))
586            .chain(
587                config
588                    .accounts
589                    .iter()
590                    .filter_map(|a| a.identity.as_ref().and_then(|i| i.email.as_ref())),
591            )
592            .chain(config.identities.iter().filter_map(|r| r.email.as_ref()))
593            .map(|a| a.to_lowercase())
594            .collect();
595        if let Ok(email) = std::env::var("EMAIL") {
596            me.push(email.to_lowercase());
597        }
598        let mut session = Session {
599            dir: dir.to_path_buf(),
600            title: dir.display().to_string(),
601            imap: None,
602            mbox: None,
603            msgs,
604            visible,
605            sel,
606            sort: SortKey::Date,
607            sort_rev: false,
608            limit: None,
609            last_search: None,
610            search_rev: false,
611            thread_depth: vec![0; count],
612            thread_root: (0..count).collect(),
613            thread_pseudo: vec![false; count],
614            thread_parent: vec![None; count],
615            thread_children: vec![Vec::new(); count],
616            thread_members: (0..count).map(|i| vec![i]).collect(),
617            collapsed: HashSet::new(),
618            undo: Vec::new(),
619            lists: config.list_matchers(),
620            subscribed: config.subscribed_matchers(),
621            alternates: config.alternate_matchers(),
622            my_addresses: me,
623            body_hits: HashMap::new(),
624            body_local: HashSet::new(),
625            server_folders: None,
626            listing: false,
627            idle: None,
628            backfill: None,
629            mailbox_new: HashMap::new(),
630            error_history: std::collections::VecDeque::new(),
631            mailbox_announced: HashSet::new(),
632            unseen: HashMap::new(),
633            read_only: false,
634            read_only_session: false,
635            dir_mtimes: dir_mtimes(dir),
636            message_hooks: Vec::new(),
637            reply_hooks: Vec::new(),
638            fcc_hooks: Vec::new(),
639            crypt_hooks: Vec::new(),
640            display: display_from_config(&config),
641            outbox: Vec::new(),
642            quote_re: default_quote_re(),
643            reply_re: compose::default_reply_regexp(),
644            attach_re: default_attach_re(),
645            config,
646            pending: None,
647            deferred: std::collections::VecDeque::new(),
648            opening: None,
649            progress_noted: false,
650            spoken: false,
651            aborted: false,
652            quit_default: true,
653            setup: None,
654            draft: None,
655            staged: None,
656            attach_confirmed: false,
657            hook_base: None,
658            active_message_hooks: Vec::new(),
659            requests: Vec::new(),
660            notices: Box::new(Silence),
661        };
662        session.apply_timeouts();
663        let mut hook_warnings = Vec::new();
664        session.quote_re = quote_re_from_config(&session.config, &mut hook_warnings);
665        session.reply_re = reply_re_from_config(&session.config, &mut hook_warnings);
666        session.attach_re = attach_re_from_config(&session.config, &mut hook_warnings);
667        session.compile_hooks_from_config(&mut hook_warnings);
668        hook_warnings.append(&mut warnings);
669        let mut warnings = hook_warnings;
670        if let Some(spec) = session.config.index.sort.clone() {
671            match parse_sort(&spec) {
672                Some((sort, rev)) => {
673                    session.sort = sort;
674                    session.sort_rev = rev;
675                    session.resort(None);
676                }
677                None => warnings.push(format!("unknown sort {spec:?} in config")),
678            }
679        }
680        Ok((session, warnings))
681    }
682
683    /// Open a mailbox by spec: an `imap:account[/folder]` string (the
684    /// folder is mirrored into a cache maildir) or a local path.
685    ///
686    /// `progress` is how the front end shows slow network work; a
687    /// library caller that has nothing to show passes a closure that
688    /// throws the lines away.
689    pub fn open_spec(
690        spec: &str,
691        config: Config,
692        progress: remote::Progress,
693    ) -> Result<(Session, Vec<String>)> {
694        // Before the first connection, not after: an unreachable
695        // server is exactly what the config's patience is for.
696        rmut_core::net::set_timeouts(config.net.connect_timeout, config.net.timeout);
697        rmut_core::net::set_trust(
698            config.net.system_cas,
699            config.net.certificate_file.as_deref().map(expand_tilde),
700        );
701        match remote::parse_spec(spec) {
702            Some((account_name, mailbox)) => {
703                let account = config
704                    .account(account_name)
705                    .with_context(|| format!("no account {account_name} in config"))?
706                    .clone();
707                let password = account_password(&account)?;
708                Session::open_remote(&account, mailbox, &password, config, progress)
709            }
710            None => {
711                let path = expand_tilde(spec);
712                if path.is_file() {
713                    return Session::open_mbox(&path, config);
714                }
715                Session::open(&path, config)
716            }
717        }
718    }
719
720    /// The network half of opening an `imap:` spec: connect, log in,
721    /// mirror the folder, and put the connection on its thread. The
722    /// password is already in hand, since asking for it may need the
723    /// terminal and this may run on a thread of its own.
724    fn open_remote(
725        account: &Account,
726        mailbox: &str,
727        password: &str,
728        config: Config,
729        progress: remote::Progress,
730    ) -> Result<(Session, Vec<String>)> {
731        let remote = Remote::open(account, mailbox, password, progress)?;
732        let cache = remote.cache.clone();
733        let (mut session, warnings) = Session::open(&cache, config)?;
734        session.title = remote.spec.clone();
735        // IDLE on a second connection; NOOP polling stays as
736        // the fallback when the server doesn't support it.
737        session.idle = Some(remote::idle_watch(account, &remote.mailbox, password));
738        session.imap = Some(Imap::new(remote));
739        Ok((session, warnings))
740    }
741
742    /// Open an mbox file (e.g. /var/mail/$USER) through its cache
743    /// mirror; `$` sync writes changes back into the file.
744    pub fn open_mbox(path: &Path, config: Config) -> Result<(Session, Vec<String>)> {
745        let mbox = mbox::Mbox::open(path)?;
746        let cache = mbox.cache.clone();
747        let (mut session, warnings) = Session::open(&cache, config)?;
748        session.title = path.display().to_string();
749        session.mbox = Some(mbox);
750        Ok((session, warnings))
751    }
752
753    /// Recompile what the session derives from the config, after a
754    /// command changed it.
755    pub fn recompile(&mut self) -> Vec<String> {
756        let mut warnings = Vec::new();
757        self.apply_timeouts();
758        self.display = display_from_config(&self.config);
759        self.quote_re = quote_re_from_config(&self.config, &mut warnings);
760        self.reply_re = reply_re_from_config(&self.config, &mut warnings);
761        self.attach_re = attach_re_from_config(&self.config, &mut warnings);
762        self.compile_hooks_from_config(&mut warnings);
763        self.lists = self.config.list_matchers();
764        self.subscribed = self.config.subscribed_matchers();
765        self.alternates = self.config.alternate_matchers();
766        warnings
767    }
768
769    /// Hand the network layer the config's patience. It is process
770    /// wide, because the connections are made from threads that have
771    /// an account but not a config.
772    fn apply_timeouts(&self) {
773        rmut_core::net::set_timeouts(self.config.net.connect_timeout, self.config.net.timeout);
774        rmut_core::net::set_trust(
775            self.config.net.system_cas,
776            self.config
777                .net
778                .certificate_file
779                .as_deref()
780                .map(expand_tilde),
781        );
782    }
783
784    /// The hook tables, compiled from the config; a bad pattern warns
785    /// and drops rather than failing the whole table.
786    fn compile_hooks_from_config(&mut self, warnings: &mut Vec<String>) {
787        self.message_hooks = compile_hooks(
788            "message-hook",
789            self.config
790                .message_hooks
791                .iter()
792                .map(|h| (&h.pattern, &h.command)),
793            warnings,
794        );
795        self.reply_hooks = compile_hooks(
796            "reply-hook",
797            self.config
798                .reply_hooks
799                .iter()
800                .map(|h| (&h.pattern, &h.command)),
801            warnings,
802        );
803        self.fcc_hooks = compile_hooks(
804            "fcc-hook",
805            self.config
806                .fcc_hooks
807                .iter()
808                .map(|h| (&h.pattern, &h.mailbox)),
809            warnings,
810        );
811        self.crypt_hooks = self
812            .config
813            .crypt_hooks
814            .iter()
815            .map(|h| (pattern::Matcher::new(&h.address), h.key.clone()))
816            .collect();
817    }
818
819    /// Open another mailbox in place: the same session, pointed
820    /// somewhere else.
821    ///
822    /// The config, the `-R` flag and the notice sink stay; everything
823    /// about the old mailbox goes. A local mailbox opens on the spot.
824    /// One on the server opens in the background while this one stays
825    /// on screen: another folder of the open account reuses the live
826    /// connection (a SELECT) instead of a fresh connect and login, and
827    /// anything else, or a SELECT that fails, connects on a thread of
828    /// its own. Keys typed meanwhile wait ([`Session::holding`]), and
829    /// Ctrl+G gives up. Either way the front end hears
830    /// [`Request::Opened`] with the warnings to show, or an error.
831    ///
832    /// `read_only` is mutt's Alt+c: the mailbox opens, and refuses to
833    /// be written to.
834    pub fn switch_to(&mut self, spec: &str, read_only: bool) {
835        if self.holding() {
836            self.error("still opening the last one (Ctrl+G gives up on it)");
837            return;
838        }
839        let Some((account, mailbox)) = remote::parse_spec(spec) else {
840            let config = self.config.clone();
841            match Session::open_spec(spec, config, Box::new(|_| {})) {
842                Ok((next, warnings)) => self.become_(next, warnings, read_only),
843                Err(err) => self.error(format!("cannot open {spec}: {err:#}")),
844            }
845            return;
846        };
847        let same_account = self
848            .imap
849            .as_ref()
850            .is_some_and(|imap| imap.facts.account.name == account);
851        if !same_account {
852            self.open_in_background(spec, read_only);
853            return;
854        }
855        let spec = spec.to_string();
856        self.send_then(
857            Job::Switch(mailbox.into()),
858            true,
859            Box::new(move |session, done| match done {
860                Ok(_) => session.switched(read_only),
861                // A session the server gave up on: the full open,
862                // with a connection of its own.
863                Err(_) => session.open_in_background(&spec, read_only),
864            }),
865        );
866    }
867
868    /// Whether a mailbox is being opened: the keys typed meanwhile
869    /// are for the mailbox that is coming, so a front end holds them
870    /// until this is false. Ctrl+G still goes through, to give up.
871    pub fn holding(&self) -> bool {
872        self.opening.is_some()
873            || self.pending.as_ref().is_some_and(Pending::holds)
874            || self.deferred.iter().any(Deferred::holds)
875    }
876
877    /// The connection has SELECTed the new folder and mirrored it:
878    /// build the session over its cache and take the connection along.
879    fn switched(&mut self, read_only: bool) {
880        let Some(imap) = self.imap.take() else {
881            return;
882        };
883        match Session::open(&imap.facts.cache.clone(), self.config.clone()) {
884            Ok((mut next, warnings)) => {
885                next.title = imap.facts.spec.clone();
886                // A second connection for IDLE, as the first open makes.
887                if let Ok(password) = account_password(&imap.facts.account) {
888                    next.idle = Some(remote::idle_watch(
889                        &imap.facts.account,
890                        &imap.facts.mailbox,
891                        &password,
892                    ));
893                }
894                next.imap = Some(imap);
895                // The copies kept in the background ride the same
896                // connection; they are not the old mailbox's business.
897                next.deferred = mem::take(&mut self.deferred)
898                    .into_iter()
899                    .filter(Deferred::outlives_abort)
900                    .collect();
901                self.become_(next, warnings, read_only);
902            }
903            Err(err) => self.error(format!("cannot open {}: {err:#}", imap.facts.spec)),
904        }
905    }
906
907    /// Connect to `spec` on a thread of its own, the password asked
908    /// for here first. The result lands in [`Session::poll_network`].
909    fn open_in_background(&mut self, spec: &str, read_only: bool) {
910        let Some((account_name, mailbox)) = remote::parse_spec(spec) else {
911            return;
912        };
913        let prepared = self
914            .config
915            .account(account_name)
916            .with_context(|| format!("no account {account_name} in config"))
917            .cloned()
918            .and_then(|account| account_password(&account).map(|password| (account, password)));
919        let (account, password) = match prepared {
920            Ok(prepared) => prepared,
921            Err(err) => {
922                self.error(format!("cannot open {spec}: {err:#}"));
923                return;
924            }
925        };
926        let progress = Arc::new(Mutex::new(None));
927        let (tell, answer) = std::sync::mpsc::channel();
928        let config = self.config.clone();
929        let mailbox = mailbox.to_string();
930        let sink = Imap::progress_sink(&progress);
931        std::thread::spawn(move || {
932            let opened = Session::open_remote(&account, &mailbox, &password, config, sink);
933            // Nobody listening means Ctrl+G: the connection just goes.
934            let _ = tell.send(opened);
935        });
936        self.note_progress("opening the folder... (Ctrl+G aborts)".into());
937        self.opening = Some(Opening {
938            spec: spec.to_string(),
939            read_only,
940            answer,
941            progress,
942        });
943    }
944
945    /// Hand over to the session for the mailbox just opened. What
946    /// belongs to the user rather than to the mailbox comes along:
947    /// the notice sink, `-R`, and the mail still inside its
948    /// $undo_send window, which leaving a mailbox must not cancel.
949    fn become_(&mut self, mut next: Session, warnings: Vec<String>, read_only: bool) {
950        // A -R session stays read-only whatever it opens; Alt+c sets
951        // read_only for one mailbox and does not survive the switch.
952        next.read_only_session = self.read_only_session;
953        next.read_only = self.read_only_session || read_only;
954        next.notices = mem::replace(&mut self.notices, Box::new(Silence));
955        next.outbox = mem::take(&mut self.outbox);
956        *self = next;
957        // A huge IMAP folder mirrors its tail in the background.
958        self.maybe_backfill();
959        self.requests.push(Request::Opened(warnings));
960    }
961
962    /// Hand the session the front end's notice sink. Until this is
963    /// called nothing said is kept.
964    pub fn install_notices(&mut self, sink: Box<dyn NoticeSink>) {
965        self.notices = sink;
966    }
967
968    /// Forget the last notice: the front end has shown it, or the
969    /// user has moved on.
970    pub fn clear_notice(&mut self) {
971        self.notices.clear();
972        // Whatever was up went with it.
973        self.progress_noted = false;
974        self.spoken = false;
975    }
976
977    pub fn new_count(&self) -> usize {
978        self.msgs.iter().filter(|m| m.env.file.is_new).count()
979    }
980
981    pub fn deleted_count(&self) -> usize {
982        self.msgs
983            .iter()
984            .filter(|m| m.env.file.flags.deleted)
985            .count()
986    }
987
988    pub fn pending_count(&self) -> usize {
989        self.msgs.iter().filter(|m| m.pending()).count()
990    }
991
992    /// Whether the thread rooted at `root` holds anything unread.
993    fn thread_has_unread(&self, root: usize) -> bool {
994        self.thread_root
995            .iter()
996            .enumerate()
997            .filter(|&(_, &r)| r == root)
998            .any(|(i, _)| !self.msgs[i].env.file.flags.seen)
999    }
1000
1001    /// The first unread message of a thread, by path, for the cursor
1002    /// to land on when it unfolds.
1003    fn first_unread_in_thread(&self, root: usize) -> Option<PathBuf> {
1004        self.thread_root
1005            .iter()
1006            .enumerate()
1007            .filter(|&(_, &r)| r == root)
1008            .map(|(i, _)| i)
1009            .find(|&i| !self.msgs[i].env.file.flags.seen)
1010            .map(|i| self.msgs[i].env.file.path.clone())
1011    }
1012
1013    /// (depth, hidden-count-if-collapsed-root) for the index display.
1014    pub fn thread_info(&self, mi: usize) -> (usize, Option<usize>) {
1015        if self.sort != SortKey::Threads {
1016            return (0, None);
1017        }
1018        let depth = self.thread_depth.get(mi).copied().unwrap_or(0);
1019        if depth == 0 && self.collapsed.contains(&self.msgs[mi].env.file.path) {
1020            let hidden = self
1021                .thread_root
1022                .iter()
1023                .enumerate()
1024                .filter(|&(j, &r)| r == mi && j != mi)
1025                .count();
1026            if hidden > 0 {
1027                return (0, Some(hidden));
1028            }
1029        }
1030        (depth, None)
1031    }
1032
1033    pub fn select(&mut self, index: usize) {
1034        if self.visible.is_empty() {
1035            return;
1036        }
1037        self.sel = index.min(self.visible.len() - 1);
1038    }
1039
1040    pub fn cur_mut(&mut self) -> Option<&mut Msg> {
1041        let i = self.visible.get(self.sel).copied()?;
1042        self.msgs.get_mut(i)
1043    }
1044
1045    pub fn selected_path(&self) -> Option<PathBuf> {
1046        self.visible
1047            .get(self.sel)
1048            .map(|&i| self.msgs[i].env.file.path.clone())
1049    }
1050
1051    /// True (with a status note) when a mutating operation must be
1052    /// refused because of `rmut -R`.
1053    pub fn deny_readonly(&mut self) -> bool {
1054        if self.read_only {
1055            self.error("Mailbox is read-only.");
1056        }
1057        self.read_only
1058    }
1059
1060    pub fn mark_read(&mut self) {
1061        if self.read_only {
1062            return;
1063        }
1064        if let Some(m) = self.cur_mut()
1065            && (m.env.file.is_new || !m.env.file.flags.seen)
1066        {
1067            m.env.file.is_new = false;
1068            m.env.file.flags.seen = true;
1069            m.dirty = true;
1070        }
1071    }
1072
1073    /// What the pattern engine needs beyond one message: my addresses,
1074    /// the configured mailing lists, and the place in the list.
1075    pub fn scope(&self, position: pattern::Position) -> pattern::Scope<'_> {
1076        pattern::Scope {
1077            me: self.me(),
1078            lists: &self.lists,
1079            subscribed: &self.subscribed,
1080            position,
1081            thread: None,
1082        }
1083    }
1084
1085    /// The scope with the message's place in its thread filled in, for
1086    /// the thread terms. Only a threaded index has threads; elsewhere
1087    /// `~(P)` reads as P and the rest are false, as documented.
1088    pub fn scope_at(&self, position: pattern::Position, mi: usize) -> pattern::Scope<'_> {
1089        let thread = (self.sort == SortKey::Threads && mi < self.msgs.len()).then(|| {
1090            let root = self.thread_root.get(mi).copied().unwrap_or(mi);
1091            pattern::ThreadView {
1092                members: self.thread_members.get(root).map_or(&[][..], Vec::as_slice),
1093                parent: self.thread_parent.get(mi).copied().flatten(),
1094                children: self.thread_children.get(mi).map_or(&[][..], Vec::as_slice),
1095                collapsed: self.thread_depth.get(mi) == Some(&0)
1096                    && self.collapsed.contains(&self.msgs[mi].env.file.path),
1097                envs: self,
1098            }
1099        });
1100        pattern::Scope {
1101            thread,
1102            ..self.scope(position)
1103        }
1104    }
1105
1106    /// Which addresses are mine: the identity ones plus `alternates`.
1107    pub fn me(&self) -> pattern::Me<'_> {
1108        pattern::Me::new(&self.my_addresses, &self.alternates)
1109    }
1110
1111    /// Server-aware pattern match: `~b` terms resolved by UID SEARCH
1112    /// (when `resolve_body_terms` filled the sets) instead of local
1113    /// body reads, and the message's place in the list carried along
1114    /// for `~m` and `~=`.
1115    fn env_matches_at(
1116        &self,
1117        patterns: &[Pattern],
1118        env: &Envelope,
1119        pos: pattern::Position,
1120        mi: usize,
1121    ) -> bool {
1122        pattern::matches_in(
1123            patterns,
1124            env,
1125            self.scope_at(pos, mi),
1126            Some(&|env: &Envelope, m: &pattern::Matcher| {
1127                let set = self.body_hits.get(m.raw())?;
1128                let uid = remote::uid_of(&env.file.path)?;
1129                Some(set.contains(&uid))
1130            }),
1131        )
1132    }
1133
1134    /// `~m` numbering and `~=` duplicate flags for every message, as
1135    /// the index stands right now: numbers are the ones on screen, so
1136    /// a range means what the user can actually see, and messages
1137    /// hidden by the current limit carry number 0 (never in range).
1138    fn positions(&self) -> Vec<pattern::Position> {
1139        let mut seen: HashMap<&str, usize> = HashMap::new();
1140        for m in &self.msgs {
1141            if let Some(id) = m.env.msg_id.as_deref() {
1142                *seen.entry(id).or_default() += 1;
1143            }
1144        }
1145        let mut numbers = vec![0usize; self.msgs.len()];
1146        for (n, &mi) in self.visible.iter().enumerate() {
1147            if let Some(slot) = numbers.get_mut(mi) {
1148                *slot = n + 1;
1149            }
1150        }
1151        let current = self
1152            .visible
1153            .get(self.sel)
1154            .and_then(|&mi| numbers.get(mi).copied())
1155            .unwrap_or(0);
1156        let last = self.visible.len();
1157        (0..self.msgs.len())
1158            .map(|i| pattern::Position {
1159                number: numbers[i],
1160                current,
1161                last,
1162                duplicate: self.msgs[i]
1163                    .env
1164                    .msg_id
1165                    .as_deref()
1166                    .is_some_and(|id| seen.get(id).copied().unwrap_or(0) > 1),
1167            })
1168            .collect()
1169    }
1170
1171    /// On IMAP, the pattern's `~b` terms asked of the server before
1172    /// it runs. Only plain substrings go (regex or non-ASCII terms stay
1173    /// local; a server search is a literal match), and a term the
1174    /// server would not search is read locally instead.
1175    ///
1176    /// True when every term is settled and the caller can carry on.
1177    /// False when a search went off: the caller stops, and `redo`
1178    /// runs it again from the top once the answer is in, with the
1179    /// keys typed meanwhile held back for it.
1180    fn body_terms_ready(
1181        &mut self,
1182        patterns: &[Pattern],
1183        redo: Box<dyn FnOnce(&mut Session) + Send>,
1184    ) -> bool {
1185        if self.imap.is_none() {
1186            return true;
1187        }
1188        let Some(term) = pattern::body_terms(patterns).into_iter().find(|term| {
1189            let simple = !term
1190                .chars()
1191                .any(|c| r".*+?[](){}|^$\".contains(c) || !c.is_ascii());
1192            simple && !self.body_hits.contains_key(term) && !self.body_local.contains(term)
1193        }) else {
1194            return true;
1195        };
1196        self.send_then(
1197            Job::SearchBody(term.clone()),
1198            true,
1199            Box::new(move |session, done| {
1200                match done {
1201                    Ok(Done::Uids(uids)) => {
1202                        session.body_hits.insert(term, uids.into_iter().collect());
1203                    }
1204                    _ => {
1205                        session.body_local.insert(term);
1206                    }
1207                }
1208                redo(session);
1209            }),
1210        );
1211        false
1212    }
1213
1214    /// Re-read the maildir, keeping unsynced flag changes and deletion
1215    /// marks for messages that are still there.
1216    pub fn rescan(&mut self) {
1217        let Ok(files) = maildir::scan(&self.dir) else {
1218            return;
1219        };
1220        let keep = self.selected_path();
1221        let mut old: std::collections::HashMap<PathBuf, Msg> = self
1222            .msgs
1223            .drain(..)
1224            .map(|m| (m.env.file.path.clone(), m))
1225            .collect();
1226        let mut arrived = 0usize;
1227        let mut arrivals: Vec<PathBuf> = Vec::new();
1228        for file in files {
1229            match old.remove(&file.path) {
1230                Some(prev) if prev.pending() => self.msgs.push(prev),
1231                Some(mut prev) => {
1232                    // Take fresh on-disk flags, keep the parsed envelope.
1233                    prev.env.file = file;
1234                    self.msgs.push(prev);
1235                }
1236                None => {
1237                    if let Ok(env) = message::envelope(file) {
1238                        if env.file.is_new {
1239                            arrived += 1;
1240                            arrivals.push(env.file.path.clone());
1241                        }
1242                        self.msgs.push(Msg {
1243                            env,
1244                            dirty: false,
1245                            purge: false,
1246                        });
1247                    }
1248                }
1249            }
1250        }
1251        self.dir_mtimes = dir_mtimes(&self.dir);
1252        self.resort(keep.clone());
1253        // mutt's $uncollapse_new: a folded thread that just grew
1254        // unfolds, so the arrival is on screen and not a count.
1255        if self.sort == SortKey::Threads
1256            && self.config.index.uncollapse_new.unwrap_or(true)
1257            && !self.collapsed.is_empty()
1258        {
1259            let mut unfolded = false;
1260            for path in &arrivals {
1261                if let Some(mi) = self.msgs.iter().position(|m| &m.env.file.path == path) {
1262                    let root = self.thread_root.get(mi).copied().unwrap_or(mi);
1263                    unfolded |= self.collapsed.remove(&self.msgs[root].env.file.path);
1264                }
1265            }
1266            if unfolded {
1267                self.rebuild_visible(keep);
1268            }
1269        }
1270        if arrived > 0 {
1271            let title = self.title.clone();
1272            self.notify(Notice::NewMail(format!("new mail in {title} (+{arrived})")));
1273            self.run_new_mail_command(&title, arrived);
1274        }
1275    }
1276
1277    /// neomutt's new_mail_command: fire-and-forget shell hook on
1278    /// arrivals; %f = the mailbox, %n = how many.
1279    fn run_new_mail_command(&self, mailbox: &str, count: usize) {
1280        let Some(cmd) = &self.config.mail.new_mail_command else {
1281            return;
1282        };
1283        let cmd = cmd
1284            .replace("%f", &format!("'{}'", mailbox.replace('\'', r"'\''")))
1285            .replace("%n", &count.to_string());
1286        let _ = Command::new("sh")
1287            .arg("-c")
1288            .arg(cmd)
1289            .stdout(Stdio::null())
1290            .stderr(Stdio::null())
1291            .spawn();
1292    }
1293
1294    /// Look for new mail: the server first, then the mbox file, the
1295    /// other configured mailboxes, and this one's own maildir.
1296    ///
1297    /// The server's part goes off to the connection's thread and the
1298    /// rest follows when it answers, so a poll tick never stops the
1299    /// screen. Everything local happens straight away.
1300    pub fn check_new_mail(&mut self) {
1301        // The tick never waits for the connection: whatever is in
1302        // flight is a user's, and the next tick is soon enough.
1303        if self.pending.is_some() {
1304            return;
1305        }
1306        let backfilling = self.backfill.as_ref().is_some_and(|b| !b.done());
1307        // While the backfill streams headers in, skip the server
1308        // check, since a full reconcile would refetch its tail
1309        // synchronously; the rescan below integrates the files.
1310        if !backfilling && self.start(Job::CheckNew, Pending::CheckNew) {
1311            return;
1312        }
1313        self.check_local_mail();
1314    }
1315
1316    /// The half that needs no server: the mbox mirror, the other
1317    /// mailboxes' counts, and this mailbox's own rescan.
1318    fn check_local_mail(&mut self) {
1319        self.maybe_backfill();
1320        if let Some(mbox) = &mut self.mbox {
1321            // Re-mirror when the file changed; same rescan pickup.
1322            if let Err(err) = mbox.refresh() {
1323                self.error(format!("mbox: {err:#}"));
1324            }
1325        }
1326        self.check_other_mailboxes();
1327        // The counts moved: whatever shows them wants redrawing.
1328        self.requests.push(Request::MailboxesChanged);
1329        // mutt's $check_new: off, a local maildir is not rescanned
1330        // while open; what the server says still lands (IMAP is
1331        // unaffected, as in mutt).
1332        let check_new = self.config.mail.check_new.unwrap_or(true) || self.imap.is_some();
1333        if check_new && dir_mtimes(&self.dir) != self.dir_mtimes {
1334            self.rescan();
1335        }
1336        // The server's own counts follow when it gets round to them,
1337        // unless a user operation is waiting its turn: that goes first.
1338        if self.deferred.is_empty() {
1339            self.refresh_unseen();
1340        }
1341    }
1342
1343    /// Collect whatever the connection has finished, and carry on the
1344    /// operation that was waiting for it. A front end calls this every
1345    /// time round its loop; it costs nothing when nothing is running.
1346    pub fn poll_network(&mut self) {
1347        self.poll_opening();
1348        if let Some(line) = self.imap.as_mut().and_then(Imap::take_progress) {
1349            // After an abort the line is stale news, and picking it
1350            // up would take the abort's own note off the screen.
1351            if !self.aborted {
1352                self.note_progress(format!("{line} (Ctrl+G aborts)"));
1353            }
1354        }
1355        let Some(done) = self.imap.as_mut().and_then(Imap::collect) else {
1356            return;
1357        };
1358        // The progress line has served its purpose; whatever the
1359        // operation has to say goes in its place.
1360        if mem::take(&mut self.progress_noted) {
1361            self.clear_notice();
1362        }
1363        if let Some(pending) = self.pending.take() {
1364            self.resume(pending, done);
1365        }
1366        // The connection is free: what stood aside for the tick gets
1367        // it now, in the order it asked.
1368        while self.pending.is_none()
1369            && let Some(next) = self.deferred.pop_front()
1370        {
1371            match next {
1372                Deferred::Again(again) => self.run_again(again),
1373                Deferred::Job(job, pending) => {
1374                    self.start(job, pending);
1375                }
1376            }
1377        }
1378    }
1379
1380    /// The background open, carried on: its progress on the message
1381    /// line, and the new session in place once it is ready.
1382    fn poll_opening(&mut self) {
1383        let Some(opening) = &self.opening else {
1384            return;
1385        };
1386        let line = opening
1387            .progress
1388            .lock()
1389            .ok()
1390            .and_then(|mut slot| slot.take());
1391        let answer = match opening.answer.try_recv() {
1392            Ok(answer) => answer,
1393            Err(TryRecvError::Empty) => {
1394                if let Some(line) = line {
1395                    self.note_progress(format!("{line} (Ctrl+G aborts)"));
1396                }
1397                return;
1398            }
1399            Err(TryRecvError::Disconnected) => Err(anyhow::anyhow!("the connection is gone")),
1400        };
1401        let Some(opening) = self.opening.take() else {
1402            return;
1403        };
1404        if mem::take(&mut self.progress_noted) {
1405            self.clear_notice();
1406        }
1407        match answer {
1408            Ok((next, warnings)) => self.become_(next, warnings, opening.read_only),
1409            Err(err) => self.error(format!("cannot open {}: {err:#}", opening.spec)),
1410        }
1411    }
1412
1413    /// What the connection is doing, for a front end that says so.
1414    pub fn busy(&self) -> Option<&'static str> {
1415        if self.opening.is_some() {
1416            return Some("opening the folder");
1417        }
1418        self.imap.as_ref().and_then(Imap::busy)
1419    }
1420
1421    /// mutt's Ctrl+G: give up on whatever the connection is doing.
1422    /// The operation waiting for it says it was aborted; the next one
1423    /// gets a fresh connection.
1424    pub fn abort_network(&mut self) {
1425        let Some(what) = self.busy() else {
1426            return;
1427        };
1428        if self.opening.take().is_some() {
1429            // The thread finishes on its own and finds nobody to
1430            // hand the connection to; this session never moved.
1431            self.note(format!("aborted: {what}"));
1432            self.progress_noted = false;
1433            return;
1434        }
1435        if let Some(imap) = &self.imap {
1436            imap.abort();
1437        }
1438        self.note(format!("aborted: {what}"));
1439        // Giving up covers what was queued behind it too, but for
1440        // the copies kept in the background.
1441        self.deferred.retain(Deferred::outlives_abort);
1442        self.listing = false;
1443        // This note replaces the progress line rather than following
1444        // it, so it must not be swept away when the answer lands.
1445        self.progress_noted = false;
1446        self.aborted = true;
1447    }
1448
1449    /// Send a job off with the operation waiting for it. False when
1450    /// there is no connection to send it to, and the caller carries
1451    /// on by itself.
1452    ///
1453    /// With a job already in flight this one waits its turn, since
1454    /// the answers come back in order and each belongs to its own.
1455    fn start(&mut self, job: Job, pending: Pending) -> bool {
1456        if self.imap.is_none() {
1457            return false;
1458        }
1459        if self.pending.is_some() {
1460            self.deferred.push_back(Deferred::Job(job, pending));
1461            return true;
1462        }
1463        let Some(imap) = &mut self.imap else {
1464            return false;
1465        };
1466        let what = job.what();
1467        match imap.start(job) {
1468            Ok(()) => {
1469                // Say what is happening before the connection has
1470                // anything of its own to report, so a slow server
1471                // never looks like a hung one.
1472                self.note_progress(format!("{what}... (Ctrl+G aborts)"));
1473                self.pending = Some(pending);
1474                true
1475            }
1476            Err(err) => {
1477                self.error(format!("imap: {err:#}"));
1478                false
1479            }
1480        }
1481    }
1482
1483    /// A progress line on the message line, unless something the
1484    /// user has not read yet is there: a progress line only ever
1485    /// replaces another, or nothing.
1486    fn note_progress(&mut self, line: String) {
1487        if self.progress_noted || !self.spoken {
1488            self.note(line);
1489            self.spoken = false;
1490            self.progress_noted = true;
1491        }
1492    }
1493
1494    /// Send a job whose answer goes to `then`, which also hears it
1495    /// when there is no connection to send it to. `hold`: the user is
1496    /// waiting on it (see [`Session::holding`]).
1497    fn send_then(&mut self, job: Job, hold: bool, then: Then) {
1498        if self.imap.is_none() {
1499            then(self, Err(anyhow::anyhow!("not connected")));
1500            return;
1501        }
1502        // start() only refuses when the channel is gone, and has said
1503        // so; the operation waiting is dropped with it.
1504        self.start(job, Pending::Then { hold, then });
1505    }
1506
1507    /// Wait for whatever is in flight and carry it on, so the next
1508    /// job starts with the connection to itself.
1509    fn settle(&mut self) {
1510        while let Some(pending) = self.pending.take() {
1511            let Some(imap) = &mut self.imap else { break };
1512            let done = imap.wait();
1513            if mem::take(&mut self.progress_noted) {
1514                self.clear_notice();
1515            }
1516            self.resume(pending, done);
1517        }
1518    }
1519
1520    fn resume(&mut self, pending: Pending, done: Result<Done>) {
1521        if mem::take(&mut self.aborted) && done.is_err() {
1522            // The error is the abort the user asked for; the note
1523            // about it is already on the message line.
1524            return;
1525        }
1526        match pending {
1527            Pending::CheckNew => {
1528                if let Err(err) = done {
1529                    self.error(format!("imap: {err:#}"));
1530                }
1531                self.check_local_mail();
1532            }
1533            Pending::Sync { purge } => match done {
1534                // Nothing applied locally: everything stays pending.
1535                Err(err) => self.error(format!("sync failed: {err:#}")),
1536                Ok(_) => self.finish_sync(purge),
1537            },
1538            Pending::Counts(specs) => {
1539                if let Ok(Done::Counts(counts)) = done {
1540                    for (spec, count) in specs.into_iter().zip(counts) {
1541                        self.unseen.insert(spec, count);
1542                    }
1543                    self.requests.push(Request::MailboxesChanged);
1544                }
1545            }
1546            Pending::Again(again) => match done {
1547                Err(err) => self.error(format!("cannot fetch message: {err:#}")),
1548                Ok(_) => self.run_again(again),
1549            },
1550            Pending::Then { then, .. } => then(self, done),
1551        }
1552    }
1553
1554    /// The operation whose bodies have arrived, run again. Nothing is
1555    /// partial now, so it goes straight through.
1556    fn run_again(&mut self, again: Again) {
1557        match again {
1558            Again::View => self.open_message(),
1559            Again::Copy {
1560                input,
1561                delete,
1562                tagged,
1563                decode,
1564            } => self.copy_message(&input, delete, tagged, decode),
1565            Again::Pipe { command, tagged } => self.pipe_message(&command, tagged),
1566            Again::Print { tagged } => self.print_current(tagged),
1567            Again::Bounce { to, tagged } => self.bounce_current(&to, tagged),
1568        }
1569    }
1570
1571    /// The bodies these messages need, here before the operation runs.
1572    ///
1573    /// A cached IMAP message starts as headers only, so anything that
1574    /// reads one has to ask the server first. True when everything is
1575    /// on disk and the caller can carry on; false when the fetch went
1576    /// off, and the operation will be run again when it lands.
1577    fn have_bodies(&mut self, paths: &[PathBuf], again: Again) -> bool {
1578        if self.imap.is_none() {
1579            return true;
1580        }
1581        let missing: Vec<PathBuf> = paths
1582            .iter()
1583            .filter(|path| remote::is_partial(path))
1584            .cloned()
1585            .collect();
1586        if missing.is_empty() {
1587            return true;
1588        }
1589        // The poll tick has the connection: wait for it in
1590        // poll_network rather than here, so the front end keeps
1591        // drawing and reading keys meanwhile.
1592        if self.pending.is_some() {
1593            self.deferred.push_back(Deferred::Again(again));
1594            return false;
1595        }
1596        !self.start(Job::FetchBodies(missing), Pending::Again(again))
1597    }
1598
1599    /// The paths an operation is about, for the fetch that comes
1600    /// before it.
1601    fn target_paths(&self, tagged: bool) -> Vec<PathBuf> {
1602        self.op_targets(tagged)
1603            .into_iter()
1604            .map(|i| self.msgs[i].env.file.path.clone())
1605            .collect()
1606    }
1607
1608    /// Whether the open IMAP folder's IDLE watcher has seen a change
1609    /// since it was last asked.
1610    pub fn idle_kick(&self) -> bool {
1611        self.idle.as_ref().is_some_and(|w| w.take_changed())
1612    }
1613
1614    /// Watch the other configured local mailboxes for growth in their
1615    /// new/ (mutt's `mailboxes`); the first poll only sets a baseline.
1616    fn check_other_mailboxes(&mut self) {
1617        let mut grew: Vec<String> = Vec::new();
1618        for spec in self.config.mail.mailboxes.clone() {
1619            if spec.starts_with("imap:") || spec == self.title {
1620                continue; // other accounts are not worth a connection
1621            }
1622            let dir = expand_tilde(&spec);
1623            if dir == self.dir || !dir.join("new").is_dir() {
1624                continue;
1625            }
1626            let count = maildir::new_count(&dir);
1627            let prev = self.mailbox_new.insert(spec.clone(), count);
1628            if let Some(p) = prev.filter(|&p| count > p) {
1629                self.run_new_mail_command(&spec, count - p);
1630                grew.push(spec);
1631            } else if !self.config.mail.mail_check_recent.unwrap_or(true) {
1632                // mutt's $mail_check_recent unset: a mailbox holding
1633                // new mail is announced whether or not it grew, once,
1634                // until it has been emptied.
1635                if count == 0 {
1636                    self.mailbox_announced.remove(&spec);
1637                } else if self.mailbox_announced.insert(spec.clone()) {
1638                    grew.push(spec);
1639                }
1640            }
1641        }
1642        // The open mailbox's own announcement (from the rescan) wins.
1643        if !grew.is_empty() && self.notice().is_none() {
1644            self.notify(Notice::NewMail(format!("new mail in {}", grew.join(", "))));
1645        }
1646    }
1647
1648    /// Spawn (or finish) the background mirror for a huge folder's
1649    /// leftover headers; the poll rescan integrates them as they land.
1650    pub fn maybe_backfill(&mut self) {
1651        if self.backfill.as_ref().is_some_and(|b| !b.done()) {
1652            return;
1653        }
1654        self.backfill = None;
1655        let Some(imap) = &mut self.imap else {
1656            return;
1657        };
1658        if imap.facts.pending_backfill.is_empty() {
1659            return;
1660        }
1661        let uids = imap.take_backfill();
1662        let facts = imap.facts.clone();
1663        let Ok(password) = account_password(&facts.account) else {
1664            return;
1665        };
1666        let count = uids.len();
1667        self.backfill = Some(remote::backfill(
1668            &facts.account,
1669            &facts.mailbox,
1670            &password,
1671            facts.cache,
1672            uids,
1673        ));
1674        self.note(format!(
1675            "loading {count} older message(s) in the background"
1676        ));
1677    }
1678
1679    /// Apply `f` to every tagged message, marking them dirty.
1680    /// mutt's error-history: what has gone wrong lately, oldest
1681    /// first. Empty when $error_history is 0.
1682    pub fn error_history(&self) -> Vec<String> {
1683        self.error_history.iter().cloned().collect()
1684    }
1685
1686    /// mutt's next-unread-mailbox: the next configured mailbox after
1687    /// the open one that holds new mail (a local new/ with files, an
1688    /// IMAP folder with an unseen count), wrapping round. None when
1689    /// no mailbox has any, which mutt reports as an error.
1690    pub fn next_unread_mailbox(&self) -> Option<String> {
1691        let boxes = &self.config.mail.mailboxes;
1692        let start = boxes
1693            .iter()
1694            .position(|spec| *spec == self.title || expand_tilde(spec) == self.dir)
1695            .map(|i| i + 1)
1696            .unwrap_or(0);
1697        (0..boxes.len())
1698            .map(|k| &boxes[(start + k) % boxes.len()])
1699            .filter(|spec| **spec != self.title && expand_tilde(spec) != self.dir)
1700            .find(|spec| {
1701                if spec.starts_with("imap:") {
1702                    self.unseen.get(*spec).copied().unwrap_or(0) > 0
1703                } else {
1704                    maildir::new_count(&expand_tilde(spec)) > 0
1705                }
1706            })
1707            .cloned()
1708    }
1709
1710    /// What a deletion has to respect: mutt's $flag_safe (a flagged
1711    /// message stays) and $delete_untag (the mark takes the tag off).
1712    pub fn delete_rules(&self) -> DeleteRules {
1713        DeleteRules {
1714            flag_safe: self.config.mail.flag_safe,
1715            untag: self.config.mail.delete_untag.unwrap_or(true),
1716        }
1717    }
1718
1719    pub fn each_tagged(&mut self, what: &str, f: impl Fn(&mut Msg)) {
1720        let tagged: Vec<usize> = (0..self.msgs.len())
1721            .filter(|&i| self.msgs[i].env.tagged)
1722            .collect();
1723        self.push_undo(what, &tagged);
1724        for &i in &tagged {
1725            let m = &mut self.msgs[i];
1726            f(m);
1727            m.dirty = true;
1728        }
1729        self.note(format!("applied to {} tagged message(s)", tagged.len()));
1730    }
1731
1732    /// The selected message's raw bytes, completing a header-only IMAP
1733    /// cache file first. Failures land in the status line.
1734    /// The messages an operation applies to: the tagged set when `;`
1735    /// asked for it, otherwise the one under the cursor. Tagged means
1736    /// tagged anywhere, limit or no limit, like the other tagged ops.
1737    /// What an operation applies to: the tagged set when `;` asked
1738    /// for it, otherwise the message under the cursor.
1739    pub fn op_targets(&self, tagged: bool) -> Vec<usize> {
1740        if tagged {
1741            return (0..self.msgs.len())
1742                .filter(|&i| self.msgs[i].env.tagged)
1743                .collect();
1744        }
1745        self.visible.get(self.sel).copied().into_iter().collect()
1746    }
1747
1748    /// Remember `indices` as they stand, so `z` can put them back.
1749    /// Steps with nothing in them are not worth a slot.
1750    pub fn push_undo(&mut self, what: &str, indices: &[usize]) {
1751        if indices.is_empty() {
1752            return;
1753        }
1754        let marks = indices.iter().map(|&i| self.mark(i)).collect();
1755        self.push_undo_step(UndoStep {
1756            what: what.to_string(),
1757            marks,
1758            sel: self.selected_path(),
1759            created: Vec::new(),
1760            note: None,
1761            rewritten: Vec::new(),
1762        });
1763    }
1764
1765    pub fn push_undo_step(&mut self, step: UndoStep) {
1766        self.undo.push(step);
1767        // Oldest first out, on either bound.
1768        while self.undo.len() > UNDO_MAX_STEPS
1769            || (self.undo.len() > 1
1770                && self.undo.iter().map(|s| s.marks.len()).sum::<usize>() > UNDO_MAX_MARKS)
1771        {
1772            self.undo.remove(0);
1773        }
1774    }
1775
1776    pub fn mark(&self, i: usize) -> MsgMark {
1777        let m = &self.msgs[i];
1778        MsgMark {
1779            path: m.env.file.path.clone(),
1780            flags: m.env.file.flags,
1781            is_new: m.env.file.is_new,
1782            tagged: m.env.tagged,
1783            dirty: m.dirty,
1784        }
1785    }
1786
1787    /// Walk back the last step: the marks it saved go back onto the
1788    /// messages that still carry those paths, and any file it created
1789    /// is removed. Writing the mailbox drops the stack, so a step here
1790    /// is always one that has not reached disk.
1791    /// Walk back the last step on the stack.
1792    pub fn undo_last(&mut self) {
1793        let Some(step) = self.undo.pop() else {
1794            self.note("nothing to undo");
1795            return;
1796        };
1797        let mut restored = 0usize;
1798        let mut rewrite_failed = Vec::new();
1799        let mut resort = false;
1800        for (path, bytes) in &step.rewritten {
1801            let at = self.msgs.iter().position(|m| &m.env.file.path == path);
1802            let put_back = std::fs::write(path, bytes)
1803                .map_err(|err| anyhow::anyhow!(err))
1804                .and_then(|()| match at {
1805                    Some(i) => self.reread(i, bytes.len() as u64),
1806                    None => Ok(()),
1807                });
1808            match put_back {
1809                Ok(()) => resort = true,
1810                Err(err) => rewrite_failed.push(format!("{}: {err}", path.display())),
1811            }
1812        }
1813        if resort {
1814            let keep = step.sel.clone();
1815            self.resort(keep);
1816        }
1817        let by_path: HashMap<&Path, usize> = self
1818            .msgs
1819            .iter()
1820            .enumerate()
1821            .map(|(i, m)| (m.env.file.path.as_path(), i))
1822            .collect();
1823        let mut wanted: Vec<(usize, MsgMark)> = Vec::new();
1824        for mark in &step.marks {
1825            if let Some(&i) = by_path.get(mark.path.as_path()) {
1826                wanted.push((i, mark.clone()));
1827            }
1828        }
1829        for (i, mark) in wanted {
1830            let m = &mut self.msgs[i];
1831            m.env.file.flags = mark.flags;
1832            m.env.file.is_new = mark.is_new;
1833            m.env.tagged = mark.tagged;
1834            m.dirty = mark.dirty;
1835            restored += 1;
1836        }
1837        let mut failed = rewrite_failed;
1838        for path in &step.created {
1839            if let Err(err) = std::fs::remove_file(path) {
1840                failed.push(format!("{}: {err}", path.display()));
1841            }
1842        }
1843        if let Some(vi) = step.sel.and_then(|p| {
1844            self.visible
1845                .iter()
1846                .position(|&i| self.msgs[i].env.file.path == p)
1847        }) {
1848            self.sel = vi;
1849        }
1850        let mut note = format!("undone: {} ({restored} message(s))", step.what);
1851        if let Some(extra) = &step.note {
1852            note += &format!("; {extra}");
1853        }
1854        if !failed.is_empty() {
1855            self.error(format!("{note}; could not remove {}", failed.join("; ")));
1856        } else {
1857            self.note(note);
1858        }
1859    }
1860
1861    /// The raw message on disk, fetched first when the IMAP cache
1862    /// holds headers only.
1863    pub fn message_bytes(&mut self, i: usize) -> Option<Vec<u8>> {
1864        // One connection, one conversation: whatever the tick has in
1865        // flight is collected first, so the answer waited for is this
1866        // job's own.
1867        self.settle();
1868        let path = self.msgs.get(i)?.env.file.path.clone();
1869        if remote::is_partial(&path)
1870            && let Some(imap) = &mut self.imap
1871            && let Err(err) = imap.blocking(Job::FetchBodies(vec![path.clone()]))
1872        {
1873            self.error(format!("cannot fetch message: {err:#}"));
1874            return None;
1875        }
1876        match std::fs::read(&path) {
1877            Ok(bytes) => Some(bytes),
1878            Err(err) => {
1879                self.error(format!("cannot read message: {err}"));
1880                None
1881            }
1882        }
1883    }
1884
1885    pub fn full_message_bytes(&mut self) -> Option<Vec<u8>> {
1886        let &i = self.visible.get(self.sel)?;
1887        self.message_bytes(i)
1888    }
1889
1890    /// Every message the operation applies to, back to back.
1891    pub fn op_bytes(&mut self, tagged: bool) -> Option<Vec<u8>> {
1892        let mut out = Vec::new();
1893        for i in self.op_targets(tagged) {
1894            let bytes = self.message_bytes(i)?;
1895            out.extend_from_slice(&bytes);
1896            if !bytes.ends_with(b"\n") {
1897                out.push(b'\n');
1898            }
1899        }
1900        Some(out)
1901    }
1902
1903    /// Rebuild `visible` from the limit and collapsed threads, keeping
1904    /// the selection on the message at `keep` when still visible.
1905    pub fn rebuild_visible(&mut self, keep: Option<PathBuf>) {
1906        let mut visible = Vec::with_capacity(self.msgs.len());
1907        let positions = self.positions();
1908        for (i, pos) in positions.iter().enumerate() {
1909            let limit_ok = match &self.limit {
1910                Some((_, patterns)) => self.env_matches_at(patterns, &self.msgs[i].env, *pos, i),
1911                None => true,
1912            };
1913            if !limit_ok {
1914                continue;
1915            }
1916            if self.limit.is_none()
1917                && self.sort == SortKey::Threads
1918                && self.thread_depth.get(i).copied().unwrap_or(0) > 0
1919            {
1920                let root = self.thread_root.get(i).copied().unwrap_or(i);
1921                if self.collapsed.contains(&self.msgs[root].env.file.path) {
1922                    continue;
1923                }
1924            }
1925            visible.push(i);
1926        }
1927        self.visible = visible;
1928        self.sel = keep
1929            .and_then(|p| {
1930                self.visible
1931                    .iter()
1932                    .position(|&i| self.msgs[i].env.file.path == p)
1933            })
1934            .unwrap_or_else(|| self.visible.len().saturating_sub(1));
1935    }
1936
1937    pub fn apply_sort(&mut self) {
1938        let keep = self.selected_path();
1939        self.resort(keep);
1940    }
1941
1942    fn resort(&mut self, keep: Option<PathBuf>) {
1943        if self.sort == SortKey::Threads {
1944            // mutt's $sort_aux: which end of a thread decides where
1945            // it sits, and which way round the threads go.
1946            let order = self
1947                .config
1948                .index
1949                .sort_aux
1950                .as_deref()
1951                .map(thread::ThreadOrder::parse)
1952                .unwrap_or_default();
1953            // mutt's $strict_threads / $sort_re: unless told otherwise,
1954            // a root whose subject repeats one already here joins it,
1955            // which is the only thing that threads mail sent without
1956            // References at all.
1957            let items = {
1958                let fallback = (!self.config.index.strict_threads.unwrap_or(false)).then(|| {
1959                    thread::SubjectFallback {
1960                        reply_re: &self.reply_re,
1961                        sort_re: self.config.index.sort_re.unwrap_or(true),
1962                    }
1963                });
1964                let envs: Vec<&Envelope> = self.msgs.iter().map(|m| &m.env).collect();
1965                thread::thread_with(&envs, order, fallback.as_ref())
1966            };
1967            let mut old: Vec<Option<Msg>> = self.msgs.drain(..).map(Some).collect();
1968            let mut new_pos = vec![0usize; old.len()];
1969            for (pos, item) in items.iter().enumerate() {
1970                new_pos[item.index] = pos;
1971            }
1972            self.msgs = items
1973                .iter()
1974                .map(|item| {
1975                    old[item.index]
1976                        .take()
1977                        .expect("thread order is a permutation")
1978                })
1979                .collect();
1980            self.thread_depth = items.iter().map(|item| item.depth).collect();
1981            self.thread_root = items.iter().map(|item| new_pos[item.root]).collect();
1982            self.thread_pseudo = items.iter().map(|item| item.pseudo).collect();
1983            self.sort_rev = false;
1984            self.index_threads();
1985        } else {
1986            let (sort, rev) = (self.sort, self.sort_rev);
1987            self.msgs.sort_by(|a, b| {
1988                let ord = match sort {
1989                    SortKey::Date => a.env.date.cmp(&b.env.date),
1990                    SortKey::From => a.env.from.to_lowercase().cmp(&b.env.from.to_lowercase()),
1991                    SortKey::Subject => {
1992                        subject_key(&a.env.subject).cmp(&subject_key(&b.env.subject))
1993                    }
1994                    SortKey::Size => a.env.file.size.cmp(&b.env.file.size),
1995                    SortKey::Label => {
1996                        // Unlabelled sorts last, as mutt has it.
1997                        let key = |e: &Envelope| {
1998                            let l = e.label.clone().unwrap_or_default().to_lowercase();
1999                            (l.is_empty(), l)
2000                        };
2001                        key(&a.env).cmp(&key(&b.env))
2002                    }
2003                    SortKey::To => {
2004                        let first_to =
2005                            |e: &Envelope| e.to.first().cloned().unwrap_or_default().to_lowercase();
2006                        first_to(&a.env).cmp(&first_to(&b.env))
2007                    }
2008                    SortKey::Unsorted => a
2009                        .env
2010                        .file
2011                        .path
2012                        .file_name()
2013                        .cmp(&b.env.file.path.file_name()),
2014                    SortKey::Threads => unreachable!(),
2015                };
2016                if rev { ord.reverse() } else { ord }
2017            });
2018            self.thread_depth = vec![0; self.msgs.len()];
2019            self.thread_root = (0..self.msgs.len()).collect();
2020            self.thread_pseudo = vec![false; self.msgs.len()];
2021            self.index_threads();
2022        }
2023        self.rebuild_visible(keep);
2024    }
2025
2026    /// Parent, children and members from the depth-first layout: a
2027    /// message's parent is the last one before it a level up.
2028    fn index_threads(&mut self) {
2029        let n = self.msgs.len();
2030        let mut parent = vec![None; n];
2031        let mut children = vec![Vec::new(); n];
2032        let mut members: Vec<Vec<usize>> = vec![Vec::new(); n];
2033        let mut last_at_depth: Vec<usize> = Vec::new();
2034        #[allow(clippy::needless_range_loop)]
2035        for i in 0..n {
2036            let depth = self.thread_depth.get(i).copied().unwrap_or(0);
2037            last_at_depth.truncate(depth);
2038            if let Some(&p) = last_at_depth.last() {
2039                parent[i] = Some(p);
2040                children[p].push(i);
2041            }
2042            last_at_depth.push(i);
2043            let root = self.thread_root.get(i).copied().unwrap_or(i);
2044            members[root].push(i);
2045        }
2046        self.thread_parent = parent;
2047        self.thread_children = children;
2048        self.thread_members = members;
2049    }
2050
2051    pub fn toggle_collapse(&mut self, all: bool) {
2052        if self.sort != SortKey::Threads {
2053            self.error("folding needs thread sort (o t)");
2054            return;
2055        }
2056        let mut keep;
2057        if all {
2058            keep = self.selected_path();
2059            if self.collapsed.is_empty() {
2060                // mutt's $collapse_unread: a thread holding unread
2061                // mail can be left open when everything else folds.
2062                let fold_unread = self.config.index.collapse_unread.unwrap_or(true);
2063                self.collapsed = (0..self.msgs.len())
2064                    .filter(|&i| self.thread_depth.get(i) == Some(&0))
2065                    .filter(|&i| fold_unread || !self.thread_has_unread(i))
2066                    .map(|i| self.msgs[i].env.file.path.clone())
2067                    .collect();
2068            } else {
2069                self.collapsed.clear();
2070            }
2071        } else {
2072            let Some(&mi) = self.visible.get(self.sel) else {
2073                return;
2074            };
2075            let root = self.thread_root.get(mi).copied().unwrap_or(mi);
2076            let path = self.msgs[root].env.file.path.clone();
2077            let unfolding = self.collapsed.remove(&path);
2078            if !unfolding {
2079                self.collapsed.insert(path.clone());
2080            }
2081            keep = Some(path);
2082            // mutt's $uncollapse_jump: land on what has not been read.
2083            if unfolding && self.config.index.uncollapse_jump {
2084                keep = self.first_unread_in_thread(root).or(keep);
2085            }
2086        }
2087        self.rebuild_visible(keep);
2088    }
2089
2090    /// The next/previous visible position from the selection; mutt's
2091    /// next-/previous-undeleted skips messages flagged for deletion.
2092    pub fn step_message(&self, forward: bool, skip_deleted: bool) -> Option<usize> {
2093        let mut pos = self.sel;
2094        loop {
2095            pos = if forward {
2096                pos + 1
2097            } else {
2098                pos.checked_sub(1)?
2099            };
2100            let &i = self.visible.get(pos)?;
2101            if !skip_deleted || !self.msgs[i].env.file.flags.deleted {
2102                return Some(pos);
2103            }
2104        }
2105    }
2106
2107    /// Tab / Alt+Tab: jump to the next (previous) new-or-unread
2108    /// message, wrapping around with a note (mutt's
2109    /// next-new-then-unread).
2110    /// The messages a thread operation applies to: the selected
2111    /// message's whole thread, or (with `sub`) the selected message
2112    /// and its replies. None when the index is not thread-sorted,
2113    /// which is also when mutt refuses.
2114    fn thread_targets(&mut self, sub: bool) -> Option<Vec<usize>> {
2115        if self.sort != SortKey::Threads {
2116            self.error("thread operations need thread sort (o t)");
2117            return None;
2118        }
2119        let &mi = self.visible.get(self.sel)?;
2120        if !sub {
2121            let root = self.thread_root.get(mi).copied().unwrap_or(mi);
2122            return Some(
2123                (0..self.msgs.len())
2124                    .filter(|&i| self.thread_root.get(i).copied().unwrap_or(i) == root)
2125                    .collect(),
2126            );
2127        }
2128        // Thread sort lays the messages out depth-first, so a
2129        // message's replies are the run after it that stays deeper.
2130        let depth = self.thread_depth.get(mi).copied().unwrap_or(0);
2131        let mut out = vec![mi];
2132        for i in (mi + 1)..self.msgs.len() {
2133            if self.thread_depth.get(i).copied().unwrap_or(0) <= depth {
2134                break;
2135            }
2136            out.push(i);
2137        }
2138        Some(out)
2139    }
2140
2141    /// mutt's delete-thread / undelete-thread / tag-thread and their
2142    /// subthread halves: one keystroke, one undo step, however many
2143    /// messages hang off it.
2144    pub fn thread_mark(&mut self, sub: bool, op: ThreadOp) {
2145        if op != ThreadOp::Tag && self.deny_readonly() {
2146            return;
2147        }
2148        let Some(&mi) = self.visible.get(self.sel) else {
2149            return;
2150        };
2151        let Some(targets) = self.thread_targets(sub) else {
2152            return;
2153        };
2154        let scope = if sub { "subthread" } else { "thread" };
2155        let (what, verb) = match op {
2156            ThreadOp::Delete => (format!("delete {scope}"), "deleted"),
2157            ThreadOp::Undelete => (format!("undelete {scope}"), "undeleted"),
2158            ThreadOp::Read => (format!("read {scope}"), "marked read"),
2159            // mutt's tag-thread follows the message under the cursor:
2160            // the whole thread takes the opposite of its tag.
2161            ThreadOp::Tag if self.msgs[mi].env.tagged => (format!("untag {scope}"), "untagged"),
2162            ThreadOp::Tag => (format!("tag {scope}"), "tagged"),
2163        };
2164        self.push_undo(&what, &targets);
2165        let want_tag = !self.msgs[mi].env.tagged;
2166        let rules = self.delete_rules();
2167        for &i in &targets {
2168            match op {
2169                ThreadOp::Delete => {
2170                    if rules.mark(&mut self.msgs[i]) {
2171                        self.msgs[i].dirty = true;
2172                    }
2173                }
2174                ThreadOp::Undelete => {
2175                    self.msgs[i].env.file.flags.deleted = false;
2176                    self.msgs[i].dirty = true;
2177                }
2178                ThreadOp::Tag => self.msgs[i].env.tagged = want_tag,
2179                ThreadOp::Read => {
2180                    let m = &mut self.msgs[i];
2181                    if !m.env.file.flags.seen || m.env.file.is_new {
2182                        m.env.file.flags.seen = true;
2183                        m.env.file.is_new = false;
2184                        m.dirty = true;
2185                    }
2186                }
2187            }
2188        }
2189        self.note(format!("{} {verb}", targets.len()));
2190        // mutt's $resolve, which is what makes deleting thread after
2191        // thread one repeated key; a rescue stays where it is.
2192        if op == ThreadOp::Delete
2193            && let Some(pos) = self.step_message(true, true)
2194        {
2195            self.sel = pos;
2196        }
2197    }
2198
2199    /// mutt's parent-message / root-message. A parent folded away
2200    /// under its root is reached as the root, which is what is on
2201    /// screen.
2202    pub fn jump_parent(&mut self, root: bool) {
2203        if self.sort != SortKey::Threads {
2204            self.error("thread operations need thread sort (o t)");
2205            return;
2206        }
2207        let Some(&mi) = self.visible.get(self.sel) else {
2208            return;
2209        };
2210        let target = if root {
2211            self.thread_root.get(mi).copied().filter(|&r| r != mi)
2212        } else {
2213            self.thread_parent.get(mi).copied().flatten()
2214        };
2215        let Some(target) = target else {
2216            self.error(if root {
2217                "already the thread root"
2218            } else {
2219                "no parent message"
2220            });
2221            return;
2222        };
2223        let tr = self.thread_root.get(target).copied().unwrap_or(target);
2224        let at = self
2225            .visible
2226            .iter()
2227            .position(|&i| i == target)
2228            .or_else(|| self.visible.iter().position(|&i| i == tr));
2229        match at {
2230            Some(vi) => self.select(vi),
2231            None => self.error("the parent is not in the current limit"),
2232        }
2233    }
2234
2235    /// Whether the selected message can have its threading headers
2236    /// rewritten: a threaded index, a mailbox that is written in
2237    /// place. IMAP and mbox keep the real message elsewhere, so the
2238    /// local copy is not the one to edit.
2239    fn can_rewrite(&mut self) -> bool {
2240        if self.deny_readonly() {
2241            return false;
2242        }
2243        if self.sort != SortKey::Threads {
2244            self.error("thread operations need thread sort (o t)");
2245            return false;
2246        }
2247        if self.imap.is_some() || self.mbox.is_some() {
2248            self.error("rewriting a message's threading is for local maildirs only");
2249            return false;
2250        }
2251        true
2252    }
2253
2254    /// Write the message back with these threading headers, and read
2255    /// it again so the index sees the change. `broken` leaves
2256    /// rmut's break marker on the message, so the subject grouping
2257    /// keeps its hands off it. Returns the bytes it held before, for
2258    /// the undo.
2259    fn rewrite_thread(
2260        &mut self,
2261        i: usize,
2262        in_reply_to: Option<&str>,
2263        references: &[String],
2264        broken: bool,
2265    ) -> Result<Vec<u8>> {
2266        let path = self.msgs[i].env.file.path.clone();
2267        let old = std::fs::read(&path).with_context(|| format!("reading {}", path.display()))?;
2268        let new = message::with_thread_headers(&old, in_reply_to, references, broken);
2269        std::fs::write(&path, &new).with_context(|| format!("writing {}", path.display()))?;
2270        self.reread(i, new.len() as u64)?;
2271        Ok(old)
2272    }
2273
2274    /// Parse the message on disk again, keeping what is not in the
2275    /// file (the tag).
2276    fn reread(&mut self, i: usize, size: u64) -> Result<()> {
2277        let mut file = self.msgs[i].env.file.clone();
2278        file.size = size;
2279        let tagged = self.msgs[i].env.tagged;
2280        let mut env = message::envelope(file)?;
2281        env.tagged = tagged;
2282        self.msgs[i].env = env;
2283        Ok(())
2284    }
2285
2286    /// mutt's break-thread: the selected message forgets its
2287    /// In-Reply-To and References, so the subthread under it becomes
2288    /// a thread of its own. The message is rewritten on disk, as
2289    /// mutt's mutt_break_thread has it rewritten on sync.
2290    pub fn break_thread(&mut self) {
2291        let Some(&mi) = self.visible.get(self.sel) else {
2292            return;
2293        };
2294        if !self.can_rewrite() {
2295            return;
2296        }
2297        // Nothing to break only when nothing holds it: a message the
2298        // subject grouping placed carries no References either, and
2299        // `#` is how it is taken out of that thread.
2300        if self.msgs[mi].env.references.is_empty() && !self.subject_threaded(mi) {
2301            self.note("already a thread of its own");
2302            return;
2303        }
2304        let path = self.msgs[mi].env.file.path.clone();
2305        let mut step = UndoStep {
2306            what: "break thread".into(),
2307            marks: vec![self.mark(mi)],
2308            sel: Some(path.clone()),
2309            created: Vec::new(),
2310            note: None,
2311            rewritten: Vec::new(),
2312        };
2313        match self.rewrite_thread(mi, None, &[], true) {
2314            Ok(old) => {
2315                step.rewritten.push((path.clone(), old));
2316                self.push_undo_step(step);
2317                self.resort(Some(path));
2318                self.note("thread broken");
2319            }
2320            Err(err) => self.error(format!("break thread: {err:#}")),
2321        }
2322    }
2323
2324    /// mutt's link-threads: the tagged messages become replies to the
2325    /// selected one. As in mutt's link_threads, each child's headers
2326    /// are replaced by an In-Reply-To naming the parent, and the
2327    /// child is untagged.
2328    pub fn link_threads(&mut self) {
2329        let Some(&mi) = self.visible.get(self.sel) else {
2330            return;
2331        };
2332        if !self.can_rewrite() {
2333            return;
2334        }
2335        let Some(parent_id) = self.msgs[mi].env.msg_id.clone() else {
2336            self.error("no Message-ID to link to");
2337            return;
2338        };
2339        let kids: Vec<usize> = (0..self.msgs.len())
2340            .filter(|&i| i != mi && self.msgs[i].env.tagged)
2341            .collect();
2342        if kids.is_empty() {
2343            self.error("first tag the message(s) to link");
2344            return;
2345        }
2346        let keep = self.msgs[mi].env.file.path.clone();
2347        let mut step = UndoStep {
2348            what: "link threads".into(),
2349            marks: kids.iter().map(|&i| self.mark(i)).collect(),
2350            sel: Some(keep.clone()),
2351            created: Vec::new(),
2352            note: None,
2353            rewritten: Vec::new(),
2354        };
2355        let mut linked = 0usize;
2356        for &i in &kids {
2357            let path = self.msgs[i].env.file.path.clone();
2358            match self.rewrite_thread(i, Some(&parent_id), &[], false) {
2359                Ok(old) => {
2360                    step.rewritten.push((path, old));
2361                    self.msgs[i].env.tagged = false;
2362                    linked += 1;
2363                }
2364                Err(err) => self.error(format!("link threads: {err:#}")),
2365            }
2366        }
2367        if linked > 0 {
2368            self.push_undo_step(step);
2369            self.resort(Some(keep));
2370            self.note(format!("{linked} linked"));
2371        }
2372    }
2373
2374    /// mutt's edit-label: write X-Label on every target, one undo
2375    /// step, and re-sort (sort=label, %y and ~y all read it). Local
2376    /// maildirs only, like the thread rewrites. An empty value clears
2377    /// the header.
2378    pub fn edit_label(&mut self, value: &str, tagged: bool) {
2379        if !self.can_rewrite_here() {
2380            return;
2381        }
2382        let targets = self.op_targets(tagged);
2383        if targets.is_empty() {
2384            return;
2385        }
2386        let value = value.trim();
2387        let new = (!value.is_empty()).then(|| value.to_string());
2388        let keep = self.selected_path();
2389        let mut step = UndoStep {
2390            what: "edit label".into(),
2391            marks: targets.iter().map(|&i| self.mark(i)).collect(),
2392            sel: keep.clone(),
2393            created: Vec::new(),
2394            note: None,
2395            rewritten: Vec::new(),
2396        };
2397        let mut done = 0usize;
2398        for &i in &targets {
2399            let path = self.msgs[i].env.file.path.clone();
2400            let old = match std::fs::read(&path) {
2401                Ok(b) => b,
2402                Err(err) => {
2403                    self.error(format!("edit label: {err}"));
2404                    continue;
2405                }
2406            };
2407            let bytes = message::with_header(&old, "X-Label", new.as_deref());
2408            if let Err(err) = std::fs::write(&path, &bytes) {
2409                self.error(format!("edit label: {err}"));
2410                continue;
2411            }
2412            step.rewritten.push((path, old));
2413            let _ = self.reread(i, bytes.len() as u64);
2414            done += 1;
2415        }
2416        if done > 0 {
2417            self.push_undo_step(step);
2418            self.resort(keep);
2419            self.note(match new {
2420                Some(_) => format!("labelled {done} message(s)"),
2421                None => format!("label cleared on {done} message(s)"),
2422            });
2423        }
2424    }
2425
2426    /// Like `can_rewrite`, but not tied to thread sort: edit-label
2427    /// works in any order.
2428    fn can_rewrite_here(&mut self) -> bool {
2429        if self.deny_readonly() {
2430            return false;
2431        }
2432        if self.imap.is_some() || self.mbox.is_some() {
2433            self.error("editing a label is for local maildirs only");
2434            return false;
2435        }
2436        true
2437    }
2438
2439    /// True when the subject fallback, not a References chain, put
2440    /// this message under its parent: mutt draws a star in the tree
2441    /// where the arrow would be, and so does rmut.
2442    pub fn subject_threaded(&self, mi: usize) -> bool {
2443        self.thread_pseudo.get(mi).copied().unwrap_or(false)
2444    }
2445
2446    /// mutt's $hide_thread_subject: true when this message is a thread
2447    /// reply whose subject repeats its parent's, so the index blanks
2448    /// it. Off by default, where mutt has it on, so rmut keeps its
2449    /// look until asked.
2450    pub fn subject_hidden(&self, mi: usize) -> bool {
2451        if !self.config.index.hide_thread_subject.unwrap_or(false) {
2452            return false;
2453        }
2454        let Some(parent) = self.thread_parent.get(mi).copied().flatten() else {
2455            return false;
2456        };
2457        match (self.msgs.get(mi), self.msgs.get(parent)) {
2458            (Some(m), Some(p)) => subject_key(&m.env.subject) == subject_key(&p.env.subject),
2459            _ => false,
2460        }
2461    }
2462
2463    /// mutt's toggle-write (%): flip the mailbox's writable state for
2464    /// the session. The -R session flag cannot be turned off this
2465    /// way, as mutt refuses too.
2466    pub fn toggle_write(&mut self) {
2467        if self.read_only_session {
2468            self.error("mailbox is read-only for the whole session (-R)");
2469            return;
2470        }
2471        self.read_only = !self.read_only;
2472        if self.read_only {
2473            self.note("mailbox marked read-only");
2474        } else {
2475            self.note("mailbox marked writable");
2476        }
2477    }
2478
2479    /// mutt's show-limit: the active limit pattern, or that there is
2480    /// none.
2481    pub fn show_limit(&mut self) {
2482        match &self.limit {
2483            Some((raw, _)) => self.note(format!("limit: {raw}")),
2484            None => self.note("no limit pattern (all messages shown)"),
2485        }
2486    }
2487
2488    /// The subject a reply carries, under $reply_regexp.
2489    pub fn reply_subject(&self, orig: &str) -> String {
2490        compose::reply_subject(orig, &self.reply_re)
2491    }
2492
2493    /// mutt's next-thread / previous-thread: the first message of the
2494    /// thread either side of this one.
2495    pub fn jump_thread(&mut self, forward: bool) {
2496        if self.sort != SortKey::Threads {
2497            self.error("thread operations need thread sort (o t)");
2498            return;
2499        }
2500        for (vi, wrapped) in wrap_order(self.visible.len(), self.sel, forward) {
2501            let mi = self.visible[vi];
2502            if self.thread_depth.get(mi).copied().unwrap_or(0) == 0 {
2503                if wrapped {
2504                    self.note("wrapped around");
2505                }
2506                self.select(vi);
2507                return;
2508            }
2509        }
2510        self.error("no other thread");
2511    }
2512
2513    pub fn jump_new(&mut self, forward: bool) {
2514        let n = self.visible.len();
2515        if n == 0 {
2516            return;
2517        }
2518        for (vi, wrapped) in wrap_order(n, self.sel, forward) {
2519            let m = &self.msgs[self.visible[vi]];
2520            if m.env.file.is_new || !m.env.file.flags.seen {
2521                if wrapped {
2522                    self.note("search wrapped");
2523                }
2524                self.select(vi);
2525                return;
2526            }
2527        }
2528        self.error("no new or unread messages");
2529    }
2530
2531    /// Apply `f` to every message matching `input`, within the active
2532    /// limit (members of folded threads included; folding is display
2533    /// only), and report the count.
2534    /// mutt's $simple_search: a bare one-word search (no `~`) expands
2535    /// through the template before parsing; anything with a `~` or
2536    /// more than one word is a pattern already and parses as typed.
2537    /// The config parse sites (color rules) do not go through here.
2538    pub fn compile_search(&self, input: &str) -> Result<Vec<Pattern>, String> {
2539        pattern::parse(&self.expand_simple(input))
2540    }
2541
2542    fn expand_simple(&self, input: &str) -> String {
2543        let word = input.trim();
2544        if word.contains('~') || word.split_whitespace().count() != 1 {
2545            return input.to_string();
2546        }
2547        let template = self
2548            .config
2549            .mail
2550            .simple_search
2551            .as_deref()
2552            .unwrap_or("~f %s | ~s %s");
2553        template.replace("%s", word)
2554    }
2555
2556    pub fn apply_pattern(&mut self, input: &str, op: PatternOp) {
2557        let flag_safe = self.config.mail.flag_safe;
2558        let verb = op.verb();
2559        if input.is_empty() {
2560            return;
2561        }
2562        let patterns = match self.compile_search(input) {
2563            Ok(p) => p,
2564            Err(err) => {
2565                self.error(format!("bad pattern: {err}"));
2566                return;
2567            }
2568        };
2569        let redo_input = input.to_string();
2570        if !self.body_terms_ready(
2571            &patterns,
2572            Box::new(move |session| session.apply_pattern(&redo_input, op)),
2573        ) {
2574            return;
2575        }
2576        let positions = self.positions();
2577        let mut hits = Vec::new();
2578        for (i, pos) in positions.iter().enumerate() {
2579            let in_limit = match &self.limit {
2580                Some((_, l)) => self.env_matches_at(l, &self.msgs[i].env, *pos, i),
2581                None => true,
2582            };
2583            if in_limit && self.env_matches_at(&patterns, &self.msgs[i].env, *pos, i) {
2584                hits.push(i);
2585            }
2586        }
2587        self.push_undo(&format!("{verb} by pattern"), &hits);
2588        for &i in &hits {
2589            op.apply(&mut self.msgs[i], flag_safe);
2590        }
2591        self.note(format!("{} {verb}", hits.len()));
2592    }
2593
2594    pub fn search_next(&mut self) {
2595        let Some(patterns) = self.last_search.clone() else {
2596            self.error("no search pattern (use /)");
2597            return;
2598        };
2599        if self.visible.is_empty() {
2600            return;
2601        }
2602        // mutt's $wrap_search: without it, `n` stops at the last (or
2603        // first) match rather than looping to the other end.
2604        let wrap = self.config.mail.wrap_search.unwrap_or(true);
2605        let positions = self.positions();
2606        for (vi, wrapped) in wrap_order(self.visible.len(), self.sel, !self.search_rev) {
2607            if wrapped && !wrap {
2608                break;
2609            }
2610            let mi = self.visible[vi];
2611            if self.env_matches_at(&patterns, &self.msgs[mi].env, positions[mi], mi) {
2612                if wrapped {
2613                    self.note("search wrapped");
2614                }
2615                self.sel = vi;
2616                return;
2617            }
2618        }
2619        self.error("not found");
2620    }
2621
2622    /// Copy every deleted message into the trash mailbox: UID COPY on
2623    /// the server for IMAP mailboxes, maildir delivery otherwise (the
2624    /// deleted mark is dropped on the copy).
2625    fn trash_deleted(&mut self, trash: &str) -> Result<()> {
2626        // One connection, one conversation: whatever the tick has in
2627        // flight is collected first, so the answer waited for is this
2628        // job's own.
2629        self.settle();
2630        // purge-message bypasses the trash, as in mutt's mx.c.
2631        let deleted: Vec<PathBuf> = self
2632            .msgs
2633            .iter()
2634            .filter(|m| m.env.file.flags.deleted && !m.purge)
2635            .map(|m| m.env.file.path.clone())
2636            .collect();
2637        match (remote::parse_spec(trash), &mut self.imap) {
2638            (Some((account, folder)), Some(imap)) if imap.facts.account.name == account => imap
2639                .blocking(Job::CopyToFolder {
2640                    paths: deleted,
2641                    mailbox: folder.to_string(),
2642                })
2643                .map(|_| ()),
2644            (Some(_), _) => anyhow::bail!("trash must be a folder of the open account"),
2645            (None, Some(_)) => {
2646                anyhow::bail!("an IMAP mailbox needs an imap:account/folder trash")
2647            }
2648            (None, None) => {
2649                let dir = expand_tilde(trash);
2650                maildir::create(&dir)?;
2651                for m in self
2652                    .msgs
2653                    .iter()
2654                    .filter(|m| m.env.file.flags.deleted && !m.purge)
2655                {
2656                    let bytes = std::fs::read(&m.env.file.path)?;
2657                    let mut flags = m.env.file.flags;
2658                    flags.deleted = false;
2659                    maildir::deliver(&dir, &bytes, flags)?;
2660                }
2661                Ok(())
2662            }
2663        }
2664    }
2665
2666    /// `purge` expunges deleted messages; without it they stay marked
2667    /// and only flag changes are written.
2668    pub fn sync(&mut self, purge: bool) {
2669        if self.deny_readonly() {
2670            return;
2671        }
2672        // $trash: purged messages move there first; a failed copy
2673        // aborts the purge. Purging inside the trash deletes for real.
2674        if purge
2675            && self.deleted_count() > 0
2676            && let Some(trash) = self.config.mail.trash.clone()
2677            && trash != self.title
2678            && expand_tilde(&trash) != self.dir
2679            && let Err(err) = self.trash_deleted(&trash)
2680        {
2681            self.error(format!("trash failed: {err:#}; nothing purged"));
2682            return;
2683        }
2684        if self.imap.is_some() {
2685            let mut deletes: Vec<PathBuf> = Vec::new();
2686            let mut flags: Vec<(PathBuf, maildir::Flags)> = Vec::new();
2687            for m in &self.msgs {
2688                if m.env.file.flags.deleted {
2689                    if purge {
2690                        deletes.push(m.env.file.path.clone());
2691                    }
2692                } else if m.dirty {
2693                    flags.push((m.env.file.path.clone(), m.env.file.flags));
2694                }
2695            }
2696            // The server takes it on its own thread; the local half
2697            // waits for the answer, since nothing may be applied here
2698            // until the server has it.
2699            if self.start(Job::Sync { flags, deletes }, Pending::Sync { purge }) {
2700                return;
2701            }
2702        }
2703        self.finish_sync(purge);
2704    }
2705
2706    /// The half of a sync that needs no server: the mbox write-back,
2707    /// the maildir renames and removals, and what to say about it.
2708    fn finish_sync(&mut self, purge: bool) {
2709        if let Some(mbox) = &mut self.mbox {
2710            // The wanted end state per message id; untouched messages
2711            // keep whatever the file already says.
2712            let mut state: HashMap<String, Option<(maildir::Flags, bool)>> = HashMap::new();
2713            for m in &self.msgs {
2714                let Some(id) = mbox::id_of(&m.env.file.path) else {
2715                    continue;
2716                };
2717                if m.env.file.flags.deleted && purge {
2718                    state.insert(id, None);
2719                } else if m.pending() {
2720                    let is_new = m.env.file.is_new && !m.dirty;
2721                    state.insert(id, Some((m.env.file.flags, is_new)));
2722                }
2723            }
2724            if let Err(err) = mbox.write_back(&state) {
2725                // Nothing applied locally: everything stays pending.
2726                self.error(format!("sync failed: {err:#}"));
2727                return;
2728            }
2729        }
2730        let keep = self.selected_path();
2731        let mut removed = 0usize;
2732        let mut saved = 0usize;
2733        let mut errors: Vec<String> = Vec::new();
2734        // mutt's $maildir_trash: a purge writes the T flag and keeps
2735        // the message, still marked, instead of unlinking it. Only a
2736        // real maildir: the server purges IMAP, the mirror mbox.
2737        let trash = self.config.mail.maildir_trash && self.imap.is_none() && self.mbox.is_none();
2738        self.msgs.retain_mut(|m| {
2739            if m.env.file.flags.deleted {
2740                if !purge {
2741                    return true; // stays marked for a later purge
2742                }
2743                if trash {
2744                    if m.dirty {
2745                        match maildir::store_flags(&m.env.file) {
2746                            Ok(path) => {
2747                                m.env.file.path = path;
2748                                m.env.file.is_new = false;
2749                                m.dirty = false;
2750                                saved += 1;
2751                            }
2752                            Err(err) => errors.push(err.to_string()),
2753                        }
2754                    }
2755                    return true;
2756                }
2757                match maildir::remove(&m.env.file) {
2758                    Ok(()) => {
2759                        removed += 1;
2760                        false
2761                    }
2762                    Err(err) => {
2763                        errors.push(err.to_string());
2764                        true
2765                    }
2766                }
2767            } else {
2768                if m.dirty {
2769                    match maildir::store_flags(&m.env.file) {
2770                        Ok(path) => {
2771                            m.env.file.path = path;
2772                            m.env.file.is_new = false;
2773                            m.dirty = false;
2774                            saved += 1;
2775                        }
2776                        Err(err) => errors.push(err.to_string()),
2777                    }
2778                }
2779                true
2780            }
2781        });
2782        self.dir_mtimes = dir_mtimes(&self.dir);
2783        // The marks are on disk now, and the paths the stack keyed on
2784        // have been renamed away: there is nothing left to walk back.
2785        self.undo.clear();
2786        self.resort(keep);
2787        match errors.is_empty() {
2788            true => self.notify(Notice::Synced {
2789                deleted: removed,
2790                updated: saved,
2791            }),
2792            false => self.note(format!("sync errors: {}", errors.join("; "))),
2793        }
2794    }
2795
2796    /// An error status: rendered in the error color with a bell,
2797    /// unlike informational notes (mutt's mutt_error vs mutt_message).
2798    /// Mailbox specs for the folder browser and for Tab completion at
2799    /// a mailbox prompt: the configured mailboxes, the open account's
2800    /// folders (IMAP LIST), and maildirs discovered next to the open
2801    /// one.
2802    /// mutt's folder management from the browser. A spec names an
2803    /// `imap:account/folder` of the open account, or a local path; a
2804    /// remote verb goes to the connection, a local one touches the
2805    /// filesystem. Returns what to say on success.
2806    pub fn create_folder(&mut self, spec: &str) -> Result<String, String> {
2807        let id = self.manage(
2808            spec,
2809            |_account, folder| Manage::Create(folder),
2810            |path| {
2811                maildir::create(&path).map_err(|e| format!("{e:#}"))?;
2812                Ok(path.display().to_string())
2813            },
2814        )?;
2815        Ok(format!("created {id}"))
2816    }
2817
2818    pub fn delete_folder(&mut self, spec: &str) -> Result<String, String> {
2819        let id = self.manage(
2820            spec,
2821            |_account, folder| Manage::Delete(folder),
2822            |path| {
2823                // A maildir is a directory tree; removing it is what
2824                // "delete this mailbox" means locally.
2825                std::fs::remove_dir_all(&path).map_err(|e| format!("{e}"))?;
2826                Ok(path.display().to_string())
2827            },
2828        )?;
2829        Ok(format!("deleted {id}"))
2830    }
2831
2832    pub fn set_subscribed(&mut self, spec: &str, on: bool) -> Result<String, String> {
2833        let verb = if on {
2834            "subscribed to"
2835        } else {
2836            "unsubscribed from"
2837        };
2838        self.manage(
2839            spec,
2840            move |_account, folder| Manage::Subscribe(folder, on),
2841            |_path| Err("subscription is an IMAP notion".into()),
2842        )
2843        .map(|shown| format!("{verb} {shown}"))
2844    }
2845
2846    /// Rename a mailbox to `new` (a bare folder name for an imap spec,
2847    /// or a path for a local maildir).
2848    pub fn rename_folder(&mut self, spec: &str, new: &str) -> Result<String, String> {
2849        if new.trim().is_empty() {
2850            return Err("no new name given".into());
2851        }
2852        match remote::parse_spec(spec) {
2853            Some((account, from)) => {
2854                let to = new.trim().to_string();
2855                self.on_account(account, Manage::Rename(from.to_string(), to.clone()))?;
2856                Ok(format!("renamed to {to}"))
2857            }
2858            None => {
2859                let from = expand_tilde(spec);
2860                let to = expand_tilde(new);
2861                std::fs::rename(&from, &to).map_err(|e| format!("{e}"))?;
2862                Ok(format!("renamed to {}", to.display()))
2863            }
2864        }
2865    }
2866
2867    /// The shared routing: an imap spec's verb goes to the account,
2868    /// a local path runs `local`. `make` builds the remote action
2869    /// from the account and folder.
2870    fn manage(
2871        &mut self,
2872        spec: &str,
2873        make: impl FnOnce(&str, String) -> Manage,
2874        local: impl FnOnce(std::path::PathBuf) -> Result<String, String>,
2875    ) -> Result<String, String> {
2876        match remote::parse_spec(spec) {
2877            Some((account, folder)) => {
2878                let action = make(account, folder.to_string());
2879                self.on_account(account, action)?;
2880                Ok(format!("imap:{account}/{folder}"))
2881            }
2882            None => local(expand_tilde(spec)),
2883        }
2884    }
2885
2886    /// Send a management action to the open IMAP account, refusing a
2887    /// spec for a different or absent account.
2888    fn on_account(&mut self, account: &str, action: Manage) -> Result<(), String> {
2889        // One connection, one conversation: whatever the tick has in
2890        // flight is collected first, so the answer waited for is this
2891        // job's own.
2892        self.settle();
2893        // Whatever it does, the folder list is not the one the server
2894        // last gave: the next browser asks again.
2895        self.server_folders = None;
2896        match &mut self.imap {
2897            Some(imap) if imap.facts.account.name == account => imap
2898                .blocking(Job::Manage(action))
2899                .map(drop)
2900                .map_err(|e| format!("{e:#}")),
2901            Some(_) | None => Err("that mailbox is not on the open account".into()),
2902        }
2903    }
2904
2905    /// The mailboxes a folder browser or mailbox completion offers:
2906    /// the configured ones, and the open account's folders (or the
2907    /// maildirs beside a local one).
2908    ///
2909    /// The server's folders are its last listing, so this never
2910    /// waits: with none yet a LIST goes off, and
2911    /// [`Request::FoldersChanged`] says when to ask again.
2912    pub fn folder_candidates(&mut self) -> Vec<(String, usize)> {
2913        // Local entries carry their new/ count; imap: specs of other
2914        // accounts show without one (no connection just for a count).
2915        let mut dirs: Vec<(String, usize)> = self
2916            .config
2917            .mail
2918            .mailboxes
2919            .iter()
2920            .filter(|m| m.starts_with("imap:") || expand_tilde(m).join("cur").is_dir())
2921            .map(|m| {
2922                let count = if m.starts_with("imap:") {
2923                    0
2924                } else {
2925                    maildir::new_count(&expand_tilde(m))
2926                };
2927                (m.clone(), count)
2928            })
2929            .collect();
2930        match &self.imap {
2931            Some(imap) => {
2932                let account = imap.facts.account.name.clone();
2933                match &self.server_folders {
2934                    Some(folders) => dirs.extend(
2935                        folders
2936                            .iter()
2937                            .map(|(f, unseen)| (format!("imap:{account}/{f}"), *unseen)),
2938                    ),
2939                    None => self.refresh_folders(),
2940                }
2941            }
2942            None => dirs.extend(
2943                maildir::discover(&self.dir)
2944                    .iter()
2945                    .map(|p| (p.display().to_string(), maildir::new_count(p))),
2946            ),
2947        }
2948        // One entry per name, the first spelling kept, the counts
2949        // merged; then mutt's $sort_browser over the lot.
2950        let mut seen: HashMap<String, usize> = HashMap::new();
2951        let mut unique: Vec<(String, usize)> = Vec::new();
2952        for (name, count) in dirs {
2953            match seen.get(&name) {
2954                Some(&at) => unique[at].1 = unique[at].1.max(count),
2955                None => {
2956                    seen.insert(name.clone(), unique.len());
2957                    unique.push((name, count));
2958                }
2959            }
2960        }
2961        let mut dirs = unique;
2962        sort_browser(&mut dirs, self.config.ui.sort_browser.as_deref());
2963        // Kept for the shape the old sort+dedup had: nothing to merge
2964        // now, so this is a no-op that keeps the borrow simple.
2965        dirs.dedup_by(|a, b| {
2966            if a.0 == b.0 {
2967                b.1 = b.1.max(a.1);
2968                true
2969            } else {
2970                false
2971            }
2972        });
2973        dirs
2974    }
2975
2976    /// Ask the server for its folders again, for a browser that is
2977    /// opening: it shows the last list at once, and the fresh one
2978    /// follows with [`Request::FoldersChanged`].
2979    pub fn refresh_folders(&mut self) {
2980        if self.imap.is_none() || self.listing {
2981            return;
2982        }
2983        self.listing = true;
2984        self.send_then(
2985            Job::Folders,
2986            false,
2987            Box::new(|session, done| {
2988                session.listing = false;
2989                match done {
2990                    Ok(Done::Folders(folders)) => {
2991                        session.server_folders = Some(folders);
2992                        session.requests.push(Request::FoldersChanged);
2993                    }
2994                    Ok(_) => {}
2995                    Err(err) => session.error(format!("imap: {err:#}")),
2996                }
2997            }),
2998        );
2999    }
3000
3001    /// How many unread messages a configured mailbox holds, for a
3002    /// front end drawing a sidebar.
3003    ///
3004    /// A local maildir is counted on the spot. A folder on the server
3005    /// costs a STATUS, so the number is the last one the connection
3006    /// gave: [`Session::refresh_unseen`] asks for a fresh set in the
3007    /// background, and says so when they land. Other accounts show 0
3008    /// rather than costing a connection of their own.
3009    pub fn unseen_count(&self, spec: &str) -> usize {
3010        match remote::parse_spec(spec) {
3011            Some(_) => self.unseen.get(spec).copied().unwrap_or(0),
3012            None => maildir::new_count(&expand_tilde(spec)),
3013        }
3014    }
3015
3016    /// Ask the server for the unread counts of the configured folders
3017    /// of the open account. They land on a later poll.
3018    fn refresh_unseen(&mut self) -> bool {
3019        let Some(imap) = &self.imap else {
3020            return false;
3021        };
3022        let account = imap.facts.account.name.clone();
3023        let wanted: Vec<(String, String)> = self
3024            .config
3025            .mail
3026            .mailboxes
3027            .iter()
3028            .filter_map(|spec| match remote::parse_spec(spec) {
3029                Some((a, folder)) if a == account => Some((spec.clone(), folder.to_string())),
3030                _ => None,
3031            })
3032            .collect();
3033        if wanted.is_empty() {
3034            return false;
3035        }
3036        let (specs, folders): (Vec<String>, Vec<String>) = wanted.into_iter().unzip();
3037        self.start(Job::Unseen(folders), Pending::Counts(specs))
3038    }
3039
3040    /// Put an edited message back: on IMAP the edited copy is
3041    /// appended and the original marked deleted (mutt does the same,
3042    /// since a message on a server cannot be rewritten in place),
3043    /// locally the file is written over and the mailbox rescanned.
3044    pub fn store_edited(&mut self, path: &Path, edited: &[u8]) {
3045        // One connection, one conversation: whatever the tick has in
3046        // flight is collected first, so the answer waited for is this
3047        // job's own.
3048        match &self.imap {
3049            Some(imap) => {
3050                let Some(flags) = self
3051                    .visible
3052                    .get(self.sel)
3053                    .map(|&i| self.msgs[i].env.file.flags)
3054                else {
3055                    return;
3056                };
3057                let mailbox = imap.facts.mailbox.clone();
3058                let original = path.to_path_buf();
3059                self.send_then(
3060                    Job::Append {
3061                        mailbox: Some(mailbox),
3062                        flags,
3063                        body: edited.to_vec(),
3064                    },
3065                    true,
3066                    Box::new(move |session, done| {
3067                        if let Err(err) = done {
3068                            session.error(format!("cannot store the edited copy: {err:#}"));
3069                            return;
3070                        }
3071                        let rules = session.delete_rules();
3072                        if let Some(m) = session
3073                            .msgs
3074                            .iter_mut()
3075                            .find(|m| m.env.file.path == original)
3076                            && rules.mark(m)
3077                        {
3078                            m.dirty = true;
3079                        }
3080                        session.check_new_mail();
3081                        session.note("edited copy appended; original marked deleted ($ purges)");
3082                    }),
3083                );
3084            }
3085            None => {
3086                if let Err(err) = std::fs::write(path, edited) {
3087                    self.error(format!("cannot write message: {err}"));
3088                    return;
3089                }
3090                self.rescan();
3091                self.note("message edited");
3092            }
3093        }
3094    }
3095
3096    /// mutt's $mark_old (on by default): when leaving the mailbox,
3097    /// unread new mail ages to old: moved out of new/ without the
3098    /// seen flag, shown as O and no longer counted as new.
3099    /// mark-all-read (Alt+a): every unread message in the mailbox
3100    /// marked seen, one undo step. mutt spells this as tag-pattern
3101    /// gymnastics; here it is a function of its own.
3102    pub fn mark_all_read(&mut self) {
3103        if self.deny_readonly() {
3104            return;
3105        }
3106        let targets: Vec<usize> = (0..self.msgs.len())
3107            .filter(|&i| {
3108                let f = &self.msgs[i].env.file;
3109                !f.flags.seen || f.is_new
3110            })
3111            .collect();
3112        if targets.is_empty() {
3113            self.note("no unread messages");
3114            return;
3115        }
3116        self.push_undo("mark all read", &targets);
3117        for &i in &targets {
3118            let m = &mut self.msgs[i];
3119            m.env.file.flags.seen = true;
3120            m.env.file.is_new = false;
3121            m.dirty = true;
3122        }
3123        self.note(format!("{} marked read", targets.len()));
3124    }
3125
3126    /// Tag every visible message between the two positions
3127    /// (inclusive, either order): the pointer's range gesture
3128    /// (shift+click in the window). One undo step, like any sweep.
3129    pub fn tag_span(&mut self, a: usize, b: usize) {
3130        if self.visible.is_empty() {
3131            return;
3132        }
3133        let last = self.visible.len() - 1;
3134        let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
3135        let (lo, hi) = (lo.min(last), hi.min(last));
3136        let targets: Vec<usize> = self.visible[lo..=hi].to_vec();
3137        self.push_undo("tag", &targets);
3138        for &i in &targets {
3139            self.msgs[i].env.tagged = true;
3140        }
3141        self.note(format!("{} tagged", targets.len()));
3142    }
3143
3144    pub fn mark_old_unread(&mut self) {
3145        if self.read_only || !self.config.mail.mark_old.unwrap_or(true) {
3146            return;
3147        }
3148        for m in &mut self.msgs {
3149            if m.env.file.is_new
3150                && !m.env.file.flags.seen
3151                && !m.env.file.flags.deleted
3152                && let Ok(path) = maildir::store_flags(&m.env.file)
3153            {
3154                m.env.file.path = path;
3155                m.env.file.is_new = false;
3156            }
3157        }
3158    }
3159
3160    /// Leaving the mailbox (c, sidebar open, folder browser): flag
3161    /// changes are written silently like q; only pending deletions
3162    /// block the switch. True when it is safe to go.
3163    pub fn ready_to_leave(&mut self) -> bool {
3164        if self.deleted_count() > 0 {
3165            self.error("deleted messages pending; sync with $ or undelete first");
3166            return false;
3167        }
3168        if self.pending_count() > 0 {
3169            self.sync(false);
3170            if self.pending_count() > 0 {
3171                return false; // sync failed; its status says why
3172            }
3173        }
3174        true
3175    }
3176
3177    /// Which account to submit outgoing mail through. Explicit sendmail
3178    /// configuration ($RMUT_SENDMAIL or mail.sendmail) wins; otherwise
3179    /// the open mailbox's account, or the first one with an smtp_host.
3180    pub fn smtp_account(&self) -> Option<Account> {
3181        if std::env::var("RMUT_SENDMAIL").is_ok() || self.config.mail.sendmail.is_some() {
3182            return None;
3183        }
3184        if let Some(imap) = &self.imap
3185            && imap.facts.account.smtp_host.is_some()
3186        {
3187            return Some(imap.facts.account.clone());
3188        }
3189        self.config
3190            .accounts
3191            .iter()
3192            .find(|a| a.smtp_host.is_some())
3193            .cloned()
3194    }
3195
3196    /// Copy the message to a mailbox (local maildir path or a folder
3197    /// of the open IMAP account); with `delete` the original is marked
3198    /// deleted afterwards, mutt's s versus C.
3199    pub fn copy_message(&mut self, input: &str, delete: bool, tagged: bool, decode: bool) {
3200        if input.is_empty() {
3201            self.error("no mailbox given");
3202            return;
3203        }
3204        let targets = self.op_targets(tagged);
3205        if targets.is_empty() {
3206            return;
3207        }
3208        // A copy reads the messages, so the bodies come first.
3209        let paths = self.target_paths(tagged);
3210        if !self.have_bodies(
3211            &paths,
3212            Again::Copy {
3213                input: input.to_string(),
3214                delete,
3215                tagged,
3216                decode,
3217            },
3218        ) {
3219            return;
3220        }
3221        // A folder of the open account: one batch of APPENDs on the
3222        // connection's thread, the rest of the save when it answers.
3223        if let Some((account, folder)) = remote::parse_spec(input)
3224            && self
3225                .imap
3226                .as_ref()
3227                .is_some_and(|imap| imap.facts.account.name == account)
3228        {
3229            let target = format!("imap:{account}/{folder}");
3230            let mut errors: Vec<String> = Vec::new();
3231            let mut paths = Vec::new();
3232            let mut messages = Vec::new();
3233            for &i in &targets {
3234                match self.copy_bytes(i, decode) {
3235                    Ok(bytes) => {
3236                        paths.push(self.msgs[i].env.file.path.clone());
3237                        messages.push((self.msgs[i].env.file.flags, bytes));
3238                    }
3239                    Err(err) => errors.push(err),
3240                }
3241            }
3242            if messages.is_empty() {
3243                let verb = if delete { "save" } else { "copy" };
3244                self.error(format!("cannot {verb}: {}", errors.join("; ")));
3245                return;
3246            }
3247            let job = Job::AppendAll {
3248                mailbox: folder.to_string(),
3249                messages,
3250            };
3251            self.send_then(
3252                job,
3253                true,
3254                Box::new(move |session, done| {
3255                    let outcomes = match done {
3256                        Ok(Done::Appended(outcomes)) => outcomes,
3257                        Ok(_) => Vec::new(),
3258                        Err(err) => vec![Err(format!("{err:#}"))],
3259                    };
3260                    let tried = outcomes.len();
3261                    let mut copied = Vec::new();
3262                    for (path, outcome) in paths.iter().zip(outcomes) {
3263                        match outcome {
3264                            // Found again by path: the list may have
3265                            // moved while the server was busy.
3266                            Ok(()) => copied
3267                                .extend(session.msgs.iter().position(|m| m.env.file.path == *path)),
3268                            Err(err) => errors.push(err),
3269                        }
3270                    }
3271                    if paths.len() > tried.max(1) {
3272                        errors.push(format!("{} not tried", paths.len() - tried.max(1)));
3273                    }
3274                    let note = Some(format!("the copy in {target} stays"));
3275                    session.finish_copy(Copied {
3276                        copied,
3277                        errors,
3278                        created: Vec::new(),
3279                        target,
3280                        note,
3281                        delete,
3282                        tagged,
3283                    });
3284                }),
3285            );
3286            return;
3287        }
3288        // What the undo of this step has to take back: the copies just
3289        // delivered, and (for a save) the originals' deleted marks.
3290        let mut created: Vec<PathBuf> = Vec::new();
3291        let mut copied = Vec::new();
3292        let mut errors: Vec<String> = Vec::new();
3293        let mut target = String::new();
3294        for &i in &targets {
3295            match self.copy_one(i, input, decode, &mut created) {
3296                Ok(shown) => {
3297                    target = shown;
3298                    copied.push(i);
3299                }
3300                // One bad message does not undo the good ones: the
3301                // rest still go, and the trouble is reported after.
3302                Err(err) => errors.push(err),
3303            }
3304        }
3305        self.finish_copy(Copied {
3306            copied,
3307            errors,
3308            created,
3309            target,
3310            note: None,
3311            delete,
3312            tagged,
3313        });
3314    }
3315
3316    /// The half of a save or copy after the messages have gone: the
3317    /// undo step, the deleted marks of a save, and the report.
3318    fn finish_copy(&mut self, done: Copied) {
3319        let Copied {
3320            copied,
3321            errors,
3322            created,
3323            target,
3324            note,
3325            delete,
3326            tagged,
3327        } = done;
3328        let verb = if delete { "save" } else { "copy" };
3329        if copied.is_empty() {
3330            self.error(format!("cannot {verb}: {}", errors.join("; ")));
3331            return;
3332        }
3333        let marks = copied.iter().map(|&i| self.mark(i)).collect();
3334        self.push_undo_step(UndoStep {
3335            what: format!("{verb} to {target}"),
3336            marks,
3337            sel: self.selected_path(),
3338            created,
3339            note,
3340            rewritten: Vec::new(),
3341        });
3342        let n = copied.len();
3343        if delete {
3344            let rules = self.delete_rules();
3345            for &i in &copied {
3346                if rules.mark(&mut self.msgs[i]) {
3347                    self.msgs[i].dirty = true;
3348                }
3349            }
3350        }
3351        // mutt's $resolve, as the save path has it: a successful
3352        // untagged save steps to the next undeleted message, exactly
3353        // like delete, staying put on the last one.
3354        if delete
3355            && !tagged
3356            && errors.is_empty()
3357            && let Some(pos) = self.step_message(true, true)
3358        {
3359            self.sel = pos;
3360        }
3361        let mut status = match (delete, n) {
3362            (true, 1) => format!("saved to {target} (original marked deleted)"),
3363            (true, _) => format!("saved {n} to {target} (originals marked deleted)"),
3364            (false, 1) => format!("copied to {target}"),
3365            (false, _) => format!("copied {n} to {target}"),
3366        };
3367        if !errors.is_empty() {
3368            status += &format!("; {} failed: {}", errors.len(), errors.join("; "));
3369            self.error(status);
3370        } else {
3371            self.note(status);
3372        }
3373    }
3374
3375    /// A message's bytes as a save or copy delivers them. mutt's
3376    /// decode-save/decode-copy deliver the message as the pager shows
3377    /// it (weeded headers, decoded body); plain save keeps the bytes
3378    /// verbatim.
3379    fn copy_bytes(&mut self, i: usize, decode: bool) -> Result<Vec<u8>, String> {
3380        if decode {
3381            Ok(self.displayed_text(i)?.into_bytes())
3382        } else {
3383            self.message_bytes(i)
3384                .ok_or_else(|| "cannot read the message".into())
3385        }
3386    }
3387
3388    /// One message into `spec`, a local maildir path (created if
3389    /// missing): a folder of the open account goes through
3390    /// `copy_message`'s batch instead. Returns where it went, and
3391    /// pushes the delivered file, which undo removes.
3392    fn copy_one(
3393        &mut self,
3394        i: usize,
3395        spec: &str,
3396        decode: bool,
3397        created: &mut Vec<PathBuf>,
3398    ) -> Result<String, String> {
3399        if remote::parse_spec(spec).is_some() {
3400            return Err("can only save to a folder of the open account".into());
3401        }
3402        let flags = self.msgs[i].env.file.flags;
3403        let bytes = self.copy_bytes(i, decode)?;
3404        let dir = expand_tilde(spec);
3405        maildir::create(&dir)
3406            .and_then(|()| maildir::deliver(&dir, &bytes, flags))
3407            .map(|path| {
3408                created.push(path);
3409                dir.display().to_string()
3410            })
3411            .map_err(|err| format!("{err:#}"))
3412    }
3413
3414    /// mutt's create-alias: one line appended to the alias file.
3415    pub fn create_alias(&mut self, nick: &str, addr: &str) {
3416        if nick.is_empty() || nick.contains(char::is_whitespace) {
3417            self.error("the alias nick must be one word");
3418            return;
3419        }
3420        match alias::append_to(self.config.mail.alias_file.as_deref(), nick, addr) {
3421            Ok(_) => self.note(format!("added: alias {nick} {addr}")),
3422            Err(err) => self.error(format!("cannot save the alias: {err:#}")),
3423        }
3424    }
3425
3426    /// Pipe the raw message to a shell command, like mutt's |. With
3427    /// `;` the tagged messages are concatenated into one run of the
3428    /// command, which is what mutt does with $pipe_split unset.
3429    pub fn pipe_message(&mut self, command: &str, tagged: bool) {
3430        if command.is_empty() {
3431            self.error("no command given");
3432            return;
3433        }
3434        let paths = self.target_paths(tagged);
3435        if !self.have_bodies(
3436            &paths,
3437            Again::Pipe {
3438                command: command.to_string(),
3439                tagged,
3440            },
3441        ) {
3442            return;
3443        }
3444        let targets = self.op_targets(tagged);
3445        let n = targets.len();
3446        let decode = self.config.mail.pipe_decode.unwrap_or(false);
3447        let split = self.config.mail.pipe_split.unwrap_or(false);
3448        let sep = self
3449            .config
3450            .mail
3451            .pipe_sep
3452            .clone()
3453            .unwrap_or_else(|| "\n".into());
3454        match self.run_over(&targets, command, decode, split, &sep) {
3455            Ok(()) => self.note(match n {
3456                1 => format!("piped to {command}"),
3457                _ => format!("piped {n} messages to {command}"),
3458            }),
3459            Err(err) => self.error(format!("pipe failed: {err}")),
3460        }
3461    }
3462
3463    /// Resend the message as-is to new recipients: Resent-* headers on
3464    /// top, the rest untouched.
3465    pub fn bounce_current(&mut self, to: &str, tagged: bool) {
3466        let rcpts = compose::addresses(to);
3467        if rcpts.is_empty() {
3468            self.error(format!("cannot parse the addresses in {to:?}"));
3469            return;
3470        }
3471        let paths = self.target_paths(tagged);
3472        if !self.have_bodies(
3473            &paths,
3474            Again::Bounce {
3475                to: to.to_string(),
3476                tagged,
3477            },
3478        ) {
3479            return;
3480        }
3481        let targets = self.op_targets(tagged);
3482        let mut sent = 0usize;
3483        for i in targets {
3484            let Some(bytes) = self.message_bytes(i) else {
3485                return;
3486            };
3487            if let Err(err) = self.bounce_one(&bytes, to, &rcpts) {
3488                self.error(format!("bounce failed: {err:#}"));
3489                return;
3490            }
3491            sent += 1;
3492        }
3493        self.note(match sent {
3494            1 => format!("message bounced to {to}"),
3495            _ => format!("{sent} messages bounced to {to}"),
3496        });
3497    }
3498
3499    /// One message resent as-is: Resent-* headers on top, the rest
3500    /// untouched.
3501    fn bounce_one(&mut self, bytes: &[u8], to: &str, rcpts: &[String]) -> Result<()> {
3502        let host = maildir::hostname();
3503        let from = self
3504            .current_identity(rcpts)
3505            .from_line()
3506            .unwrap_or_else(|| default_from(&host));
3507        let text = compose::bounce_text(
3508            bytes,
3509            &from,
3510            to,
3511            &compose::rfc2822_now(),
3512            &compose::make_message_id(&host),
3513        );
3514        let envelope_from = compose::bare_address(&from).unwrap_or_else(|| from.clone());
3515        match self.smtp_account() {
3516            Some(account) => account_password(&account).and_then(|password| {
3517                smtp::send(&account, &password, &envelope_from, rcpts, text.as_bytes())
3518            }),
3519            None => run_sendmail(
3520                text.as_bytes(),
3521                self.config.mail.sendmail.as_deref(),
3522                Some(rcpts),
3523            ),
3524        }
3525    }
3526
3527    /// Open the selected message: the body if it is not here yet,
3528    /// then the view, which the front end is asked to show.
3529    pub fn open_message(&mut self) {
3530        let Some(path) = self.selected_path() else {
3531            return;
3532        };
3533        if !self.have_bodies(std::slice::from_ref(&path), Again::View) {
3534            return;
3535        }
3536        self.mark_read();
3537        match self.load_view(&path) {
3538            Ok(view) => self.requests.push(Request::ShowMessage(Box::new(view))),
3539            Err(err) => self.error(format!("cannot open message: {err:#}")),
3540        }
3541    }
3542
3543    /// Fetch (IMAP), parse, and PGP-process a message the way the
3544    /// pager shows it.
3545    pub fn load_view(&mut self, path: &Path) -> Result<message::MessageView> {
3546        // One connection, one conversation: whatever the tick has in
3547        // flight is collected first, so the answer waited for is this
3548        // job's own.
3549        self.settle();
3550        // Cached IMAP messages start header-only; get the body now.
3551        if remote::is_partial(path)
3552            && let Some(imap) = &mut self.imap
3553        {
3554            imap.blocking(Job::FetchBodies(vec![path.to_path_buf()]))
3555                .context("cannot fetch message")?;
3556            // The body is here now: %l can show its line count.
3557            if let Some(m) = self.msgs.iter_mut().find(|m| m.env.file.path == path)
3558                && let Ok(raw) = std::fs::read(path)
3559            {
3560                m.env.lines = Some(message::body_lines(&raw));
3561            }
3562        }
3563        let mut view = message::load_with(path, &self.display)?;
3564        // PGP messages: decrypt/verify via gpg, prepend the verdict
3565        // line to whatever body ends up shown.
3566        if let Ok(raw) = std::fs::read(path)
3567            && let Some(p) = pgp::view(&self.config.pgp, &raw)
3568        {
3569            match p.body {
3570                // A decrypted PGP/MIME entity is a MIME tree of its
3571                // own: render it whole, so attachments inside
3572                // encrypted mail are announced like any others.
3573                Some(pgp::Body::Entity(raw)) => {
3574                    view.body = message::render_entity(&raw, &self.display);
3575                }
3576                Some(pgp::Body::Text(text)) => view.body = text,
3577                None => {}
3578            }
3579            view.body = format!("{}\n\n{}", p.note, view.body);
3580        }
3581        Ok(view)
3582    }
3583
3584    /// The message as the pager shows it: brief (weeded) headers and
3585    /// the decoded body. Used by print, decode-save/copy, and a
3586    /// decoded pipe.
3587    fn displayed_text(&mut self, i: usize) -> Result<String, String> {
3588        let path = self.msgs[i].env.file.path.clone();
3589        let view = self
3590            .load_view(&path)
3591            .map_err(|err| format!("cannot decode: {err:#}"))?;
3592        let mut text = String::new();
3593        for (name, value) in &view.brief {
3594            text += &format!("{name}: {value}\n");
3595        }
3596        text.push('\n');
3597        text += &view.body;
3598        if !text.ends_with('\n') {
3599            text.push('\n');
3600        }
3601        Ok(text)
3602    }
3603
3604    /// Pipe the message to the print command (lpr by default). mutt's
3605    /// $print_decode (on) prints the decoded form; $print_split runs
3606    /// the command once per message.
3607    pub fn print_current(&mut self, tagged: bool) {
3608        let paths = self.target_paths(tagged);
3609        if !self.have_bodies(&paths, Again::Print { tagged }) {
3610            return;
3611        }
3612        let targets = self.op_targets(tagged);
3613        if targets.is_empty() {
3614            return;
3615        }
3616        let command = self
3617            .config
3618            .mail
3619            .print
3620            .clone()
3621            .unwrap_or_else(|| "lpr".into());
3622        let decode = self.config.mail.print_decode.unwrap_or(true);
3623        let split = self.config.mail.print_split.unwrap_or(false);
3624        let n = targets.len();
3625        match self.run_over(&targets, &command, decode, split, "\n") {
3626            Ok(()) => self.note(match n {
3627                1 => format!("printed via {command}"),
3628                _ => format!("printed {n} messages via {command}"),
3629            }),
3630            Err(err) => self.error(format!("print failed: {err}")),
3631        }
3632    }
3633
3634    /// The bytes of one message for pipe/print/save: decoded (as the
3635    /// pager shows it) or raw.
3636    fn op_one_bytes(&mut self, i: usize, decode: bool) -> Result<Vec<u8>, String> {
3637        if decode {
3638            self.displayed_text(i).map(String::into_bytes)
3639        } else {
3640            self.message_bytes(i)
3641                .map(|mut b| {
3642                    if !b.ends_with(b"\n") {
3643                        b.push(b'\n');
3644                    }
3645                    b
3646                })
3647                .ok_or_else(|| "cannot read the message".into())
3648        }
3649    }
3650
3651    /// Run `command` over the targets: once per message when `split`,
3652    /// else once over them all joined by `sep` (mutt's $pipe_split /
3653    /// $pipe_sep, and the same for print).
3654    fn run_over(
3655        &mut self,
3656        targets: &[usize],
3657        command: &str,
3658        decode: bool,
3659        split: bool,
3660        sep: &str,
3661    ) -> Result<(), String> {
3662        if split {
3663            for &i in targets {
3664                let bytes = self.op_one_bytes(i, decode)?;
3665                pipe_to(command, &bytes).map_err(|err| format!("{err:#}"))?;
3666            }
3667            return Ok(());
3668        }
3669        let mut all: Vec<u8> = Vec::new();
3670        for (n, &i) in targets.iter().enumerate() {
3671            if n > 0 {
3672                all.extend_from_slice(sep.as_bytes());
3673            }
3674            all.extend_from_slice(&self.op_one_bytes(i, decode)?);
3675        }
3676        pipe_to(command, &all).map_err(|err| format!("{err:#}"))
3677    }
3678
3679    /// The identity in effect for this mailbox (and, when known, the
3680    /// draft's recipients).
3681    pub fn current_identity(&self, rcpts: &[String]) -> rmut_core::config::Identity {
3682        self.config.identity_for(
3683            &self.title,
3684            rcpts,
3685            self.imap.as_ref().map(|imap| &imap.facts.account),
3686        )
3687    }
3688
3689    /// Indices of the message-hooks the selected message matches.
3690    pub fn matching_message_hooks(&self) -> Vec<usize> {
3691        let Some(env) = self.visible.get(self.sel).map(|&mi| &self.msgs[mi].env) else {
3692            return Vec::new();
3693        };
3694        // `~m` and `~=` want the whole list; a hook asks about one
3695        // message, so only its own numbering is filled in.
3696        let pos = pattern::Position {
3697            number: self.sel + 1,
3698            current: self.sel + 1,
3699            last: self.visible.len(),
3700            duplicate: false,
3701        };
3702        self.message_hooks
3703            .iter()
3704            .enumerate()
3705            .filter(|(_, h)| pattern::matches_in(&h.patterns, env, self.scope(pos), None))
3706            .map(|(i, _)| i)
3707            .collect()
3708    }
3709
3710    /// mutt's reply-hook: the command lines whose pattern matches the
3711    /// message being replied to. Running them is the front end's, and
3712    /// so is putting the config back afterwards.
3713    pub fn reply_hook_lines(&self, path: &Path) -> Vec<String> {
3714        if self.reply_hooks.is_empty() {
3715            return Vec::new();
3716        }
3717        let Some(env) = self.msgs.iter().find(|m| m.env.file.path == *path) else {
3718            return Vec::new();
3719        };
3720        let scope = self.scope(pattern::Position::default());
3721        self.reply_hooks
3722            .iter()
3723            .filter(|h| pattern::matches_in(&h.patterns, &env.env, scope, None))
3724            .map(|h| h.value.clone())
3725            .collect()
3726    }
3727
3728    /// mutt's fcc-hook: the mailbox the first matching entry names for
3729    /// this outgoing draft, or None when nothing matches.
3730    pub fn fcc_hook_target(&self, draft: &str, path: &Path) -> Option<String> {
3731        if self.fcc_hooks.is_empty() {
3732            return None;
3733        }
3734        let env = compose::draft_envelope(draft, path);
3735        let scope = self.scope(pattern::Position::default());
3736        self.fcc_hooks
3737            .iter()
3738            .find(|h| pattern::matches_in(&h.patterns, &env, scope, None))
3739            .map(|h| h.value.clone())
3740    }
3741
3742    /// mutt's crypt-hook: a recipient with a hook of its own is
3743    /// encrypted to that key id instead of to its address.
3744    pub fn crypt_key_for(&self, address: &str) -> Option<String> {
3745        self.crypt_hooks
3746            .iter()
3747            .find(|(m, _)| m.is_match(address))
3748            .map(|(_, key)| key.clone())
3749    }
3750
3751    /// `X` submitted: `notmuch search --output=files` into a virtual
3752    /// read-only mailbox: the hits are symlinked into a cache
3753    /// maildir (the real copies stay where they are), so viewing,
3754    /// replying, copying, and piping work while flag changes and
3755    /// deletes stay refused.
3756    /// Mirror a notmuch query into a maildir of symlinks, ready to be
3757    /// opened. Returns where it is and how many messages it holds.
3758    pub fn notmuch_mirror(&mut self, query: &str) -> Option<(PathBuf, usize)> {
3759        if query.is_empty() {
3760            return None;
3761        }
3762        let out = Command::new("notmuch")
3763            .args(["search", "--output=files", "--limit=1000", "--", query])
3764            .output();
3765        let out = match out {
3766            Ok(out) if out.status.success() => out,
3767            Ok(out) => {
3768                let err = String::from_utf8_lossy(&out.stderr);
3769                self.error(format!("notmuch: {}", err.trim()));
3770                return None;
3771            }
3772            Err(err) => {
3773                self.error(format!("notmuch: {err}"));
3774                return None;
3775            }
3776        };
3777        let stdout = String::from_utf8_lossy(&out.stdout);
3778        let files: Vec<&str> = stdout.lines().filter(|l| !l.trim().is_empty()).collect();
3779        if files.is_empty() {
3780            self.error("notmuch: no matches");
3781            return None;
3782        }
3783        if !self.ready_to_leave() {
3784            return None;
3785        }
3786        let dir = remote::cache_base().join("notmuch");
3787        let build = || -> Result<()> {
3788            for sub in ["cur", "new", "tmp"] {
3789                std::fs::create_dir_all(dir.join(sub))?;
3790            }
3791            for entry in std::fs::read_dir(dir.join("cur"))?.flatten() {
3792                let _ = std::fs::remove_file(entry.path());
3793            }
3794            for (i, file) in files.iter().enumerate() {
3795                let base = Path::new(file)
3796                    .file_name()
3797                    .map(|n| n.to_string_lossy().into_owned())
3798                    .unwrap_or_else(|| format!("{i}"));
3799                // A unique prefix avoids collisions across source
3800                // dirs; the :2, flag suffix stays parseable.
3801                let _ = std::os::unix::fs::symlink(
3802                    file,
3803                    dir.join("cur").join(format!("{i:04}.{base}")),
3804                );
3805            }
3806            Ok(())
3807        };
3808        if let Err(err) = build() {
3809            self.note(format!("notmuch mirror: {err:#}"));
3810            return None;
3811        }
3812        Some((dir, files.len()))
3813    }
3814
3815    pub fn compose_base(&self) -> Option<ComposeBase> {
3816        let &mi = self.visible.get(self.sel)?;
3817        let env = &self.msgs[mi].env;
3818        let view = message::load(&env.file.path).ok()?;
3819        let get = |name: &str| {
3820            view.all
3821                .iter()
3822                .filter(|(k, _)| k.eq_ignore_ascii_case(name))
3823                .map(|(_, v)| v.clone())
3824                .collect::<Vec<_>>()
3825                .join(", ")
3826        };
3827        let from_hdr = get("From");
3828        let (reply_to, has_reply_to) = {
3829            let rt = get("Reply-To");
3830            if rt.trim().is_empty() {
3831                (from_hdr.clone(), false)
3832            } else {
3833                let differs = rt.trim() != from_hdr.trim();
3834                (rt, differs)
3835            }
3836        };
3837        Some(ComposeBase {
3838            path: env.file.path.clone(),
3839            reply_to,
3840            from_hdr,
3841            has_reply_to,
3842            orig_to: get("To"),
3843            orig_cc: get("Cc"),
3844            list_post: compose::list_post_address(&get("List-Post")),
3845            followup_to: get("Mail-Followup-To"),
3846            from_addr: compose::addresses(&get("From"))
3847                .into_iter()
3848                .next()
3849                .unwrap_or_default(),
3850            from_display: env.from.clone(),
3851            subject: env.subject.clone(),
3852            date: env.date,
3853            msg_id: env.msg_id.clone(),
3854            references: env.references.clone(),
3855        })
3856    }
3857
3858    /// The To prompt, prefilled for replies with Reply-To (the
3859    /// question's yes) or the plain From (its no).
3860    /// Where a list reply goes: the list's own List-Post address when
3861    /// it published one, else the first To/Cc address that matches a
3862    /// configured list.
3863    pub fn list_target(&self, base: &ComposeBase) -> Option<String> {
3864        if let Some(addr) = &base.list_post {
3865            return Some(addr.clone());
3866        }
3867        let mut candidates = compose::addresses(&base.orig_to);
3868        candidates.extend(compose::addresses(&base.orig_cc));
3869        candidates
3870            .into_iter()
3871            .find(|a| self.lists.iter().any(|m| m.is_match(a)))
3872    }
3873
3874    /// mutt's $followup_to: mail going to a known list carries a
3875    /// Mail-Followup-To, so replies land on the list. Being subscribed
3876    /// leaves my own address out, since the list copy is the one I get.
3877    pub fn followup_header(&self, to: &str, cc: Option<&str>, from: &str) -> Option<String> {
3878        if self.lists.is_empty() {
3879            return None;
3880        }
3881        let mut rcpts = compose::addresses(to);
3882        rcpts.extend(compose::addresses(cc.unwrap_or_default()));
3883        if !rcpts
3884            .iter()
3885            .any(|a| self.lists.iter().any(|m| m.is_match(a)))
3886        {
3887            return None;
3888        }
3889        let subscribed = rcpts
3890            .iter()
3891            .any(|a| self.subscribed.iter().any(|m| m.is_match(a)));
3892        let value = compose::followup_to(to, cc.unwrap_or_default(), self.me(), subscribed, from);
3893        (!value.is_empty()).then_some(value)
3894    }
3895
3896    /// mutt's edit_headers (default false, like mutt): whether the
3897    /// header block is part of the editor buffer.
3898    pub fn edit_headers(&self) -> bool {
3899        self.config.mail.edit_headers.unwrap_or(false)
3900    }
3901
3902    /// Write a fresh draft file for the editor: the whole text (with
3903    /// any `my_hdr` merged in), or (with edit_headers = false) only
3904    /// the body, the header block withheld for draft_full to rejoin.
3905    pub fn stage_draft(&self, text: &str) -> Result<(PathBuf, Option<String>)> {
3906        // mutt's my_hdr lands here, so every draft the TUI opens
3907        // carries it: with edit_headers the editor shows the lines,
3908        // without it they ride along in the withheld head.
3909        let text = compose::apply_my_hdr(text, &self.config.mail.my_hdr);
3910        if self.edit_headers() {
3911            return Ok((write_draft(&text)?, None));
3912        }
3913        let (head, body) = text.split_once("\n\n").unwrap_or((text.trim_end(), ""));
3914        Ok((write_draft(body)?, Some(head.to_string())))
3915    }
3916
3917    /// From line for a new draft: reverse_name picks the address the
3918    /// replied-to message came to; otherwise the layered identity
3919    /// (global, account, matching [[identities]] rules).
3920    pub fn compose_from(&self, base: Option<&ComposeBase>, to: &str) -> Option<String> {
3921        // mutt's $reverse_realname: on (the default), the address
3922        // comes over with the name it was addressed under; off, only
3923        // the address moves and the configured name stays.
3924        let realname = self.config.identity.reverse_realname.unwrap_or(true);
3925        if self.config.identity.reverse_name
3926            && let Some(b) = base
3927            && let Some(from) = compose::reverse_from(&b.orig_to, &b.orig_cc, self.me(), realname)
3928        {
3929            let rcpts = compose::addresses(to);
3930            return Some(match self.current_identity(&rcpts).name {
3931                Some(name) if !realname && !from.contains('<') => format!("{name} <{from}>"),
3932                _ => from,
3933            });
3934        }
3935        let rcpts = compose::addresses(to);
3936        self.current_identity(&rcpts).from_line()
3937    }
3938
3939    pub fn forward_attaches(&self) -> bool {
3940        self.config.mail.forward.as_deref() == Some("attach")
3941    }
3942
3943    /// The sent copy's default target, as the Fcc line shows it. An
3944    /// fcc-hook matching the draft on screen wins, so the menu shows
3945    /// where the copy is really going.
3946    /// Where the sent copy goes by default, as the Fcc line shows it.
3947    /// An fcc-hook matching `draft` wins, so the menu shows where the
3948    /// copy is really going; delivery passes None, having resolved
3949    /// the hook when the message was sent.
3950    pub fn default_fcc(&self, draft: Option<&Compose>) -> String {
3951        if let Some(compose) = draft
3952            && let Ok(full) = draft_full(compose)
3953            && let Some(mailbox) = self.fcc_hook_target(&full, &compose.path)
3954        {
3955            return mailbox;
3956        }
3957        match &self.imap {
3958            Some(imap) => format!(
3959                "imap:{}/{}",
3960                imap.facts.account.name, imap.facts.account.sent_folder
3961            ),
3962            None => self.config.mail.sent.clone().unwrap_or_default(),
3963        }
3964    }
3965
3966    /// Initial security for a fresh draft, from the [pgp] config.
3967    pub fn default_security(&self) -> Security {
3968        Self::combine(
3969            self.config.pgp.sign_by_default,
3970            self.config.pgp.encrypt_by_default,
3971        )
3972    }
3973
3974    fn combine(sign: bool, encrypt: bool) -> Security {
3975        match (sign, encrypt) {
3976            (true, true) => Security::Both,
3977            (true, false) => Security::Sign,
3978            (false, true) => Security::Encrypt,
3979            (false, false) => Security::None,
3980        }
3981    }
3982
3983    /// The security a fresh draft starts with. mutt's reply-crypto:
3984    /// replying to a signed message can default to signed
3985    /// ($crypt_replysign), to an encrypted one to encrypted
3986    /// ($crypt_replyencrypt), and to signed-and-encrypted mail to
3987    /// signed too ($crypt_replysignencrypted). Detection reads the
3988    /// original's MIME type only, never decrypting.
3989    pub fn security_for(&self, kind: &ComposeKind, base: Option<&ComposeBase>) -> Security {
3990        let mut sign = self.config.pgp.sign_by_default;
3991        let mut encrypt = self.config.pgp.encrypt_by_default;
3992        let is_reply = matches!(
3993            kind,
3994            ComposeKind::Reply | ComposeKind::GroupReply | ComposeKind::ListReply
3995        );
3996        if is_reply
3997            && let Some(base) = base
3998            && let Ok(raw) = std::fs::read(&base.path)
3999        {
4000            let crypto = rmut_core::pgp::classify(&raw);
4001            if crypto.encrypted && self.config.pgp.reply_encrypt {
4002                encrypt = true;
4003            }
4004            if crypto.signed && self.config.pgp.reply_sign {
4005                sign = true;
4006            }
4007            // Signed-and-encrypted: mutt keys the sign default off a
4008            // separate option, since the encryption already hid the
4009            // signature. rmut only sees one of the two from the MIME
4010            // type, so this applies when the encrypted original is
4011            // being answered under reply_sign_encrypted.
4012            if crypto.encrypted && self.config.pgp.reply_sign_encrypted {
4013                sign = true;
4014            }
4015        }
4016        Self::combine(sign, encrypt)
4017    }
4018
4019    /// Anything due in the outbox goes out; whatever still waits owns
4020    /// the status line, counting down.
4021    pub fn tick_outbox(&mut self) {
4022        // A mailbox on its way in: the mail goes out from it, a
4023        // moment later, rather than from under the switch.
4024        if self.holding() {
4025            return;
4026        }
4027        while self.outbox.first().is_some_and(|h| h.due <= Instant::now()) {
4028            let held = self.outbox.remove(0);
4029            self.deliver(held);
4030        }
4031        if let Some(next) = self.outbox.first()
4032            && !self.notice().is_some_and(|n| n.is_error())
4033        {
4034            let left = next.due.saturating_duration_since(Instant::now()).as_secs() + 1;
4035            self.note(format!("sending {} in {left}s (z cancels)", next.label));
4036        }
4037    }
4038
4039    /// Send everything still waiting, on the way out: the messages
4040    /// were confirmed, only `z` takes one back.
4041    /// Send everything still waiting, on the way out: the messages
4042    /// were confirmed, only `z` takes one back. Trouble comes back as
4043    /// lines for the front end to print once the terminal is its own
4044    /// again, since nobody would see a message line by then.
4045    pub fn flush_outbox(&mut self) -> Vec<String> {
4046        let mut trouble = Vec::new();
4047        while !self.outbox.is_empty() {
4048            let held = self.outbox.remove(0);
4049            let label = held.label.clone();
4050            self.deliver(held);
4051            if let Some(err) = self.notice().filter(|n| n.is_error()).map(|n| n.text()) {
4052                trouble.push(format!("{label}: {err}"));
4053                self.clear_notice();
4054            }
4055        }
4056        trouble
4057    }
4058
4059    /// Hold a sent message for its $undo_send window. It goes out on
4060    /// the next tick after it falls due, or when the session is
4061    /// flushed on the way out.
4062    pub fn hold_send(&mut self, held: Held) {
4063        self.outbox.push(held);
4064    }
4065
4066    /// Take the newest held message back, draft and all. None when
4067    /// nothing was waiting.
4068    pub fn cancel_send(&mut self) -> bool {
4069        let Some(held) = self.outbox.pop() else {
4070            return false;
4071        };
4072        self.note(format!("send cancelled: {}", held.label));
4073        self.hand_back(held.state);
4074        true
4075    }
4076
4077    /// Transmit a held message and keep the Fcc copy.
4078    /// Transmit a held message and keep the Fcc copy. A send that
4079    /// fails hands the draft back, for the front end to put on screen
4080    /// again.
4081    pub fn deliver(&mut self, held: Held) {
4082        // One connection, one conversation: whatever the tick has in
4083        // flight is collected first, so the answer waited for is this
4084        // job's own.
4085        self.settle();
4086        let Held {
4087            state: compose_state,
4088            text: final_text,
4089            fcc: chosen,
4090            ..
4091        } = held;
4092        let send_result = match self.smtp_account() {
4093            Some(account) => send_via_smtp(&account, &final_text),
4094            None => run_sendmail(
4095                final_text.as_bytes(),
4096                self.config.mail.sendmail.as_deref(),
4097                None,
4098            ),
4099        };
4100        match send_result {
4101            Ok(()) => {
4102                let mut note = String::from("message sent");
4103                let mut fcc_to_server = None;
4104                let skip_copy = chosen.as_deref() == Some("")
4105                    || (chosen.is_none() && self.config.mail.copy == Some(false));
4106                if skip_copy {
4107                    // Nothing kept, on request.
4108                } else if let Some(fcc) = chosen
4109                    .as_deref()
4110                    .filter(|f| Some(*f) != Some(self.default_fcc(None).as_str()))
4111                {
4112                    // An explicit Fcc: a local maildir path.
4113                    let dir = expand_tilde(fcc);
4114                    let flags = maildir::Flags {
4115                        seen: true,
4116                        ..Default::default()
4117                    };
4118                    if dir.join("cur").is_dir()
4119                        && maildir::deliver(&dir, final_text.as_bytes(), flags).is_ok()
4120                    {
4121                        note += &format!(", copy in {fcc}");
4122                    } else {
4123                        note += &format!(", Fcc to {fcc} failed");
4124                    }
4125                } else {
4126                    match &mut self.imap {
4127                        // Fcc goes to the account's Sent folder on the
4128                        // server.
4129                        // The server's copy is kept in the background:
4130                        // the send is done, and says so now.
4131                        Some(_) => fcc_to_server = Some(final_text.clone().into_bytes()),
4132                        None => {
4133                            let sent_dir = self
4134                                .config
4135                                .mail
4136                                .sent
4137                                .as_deref()
4138                                .map(expand_tilde)
4139                                .filter(|p| p.join("cur").is_dir())
4140                                .or_else(|| {
4141                                    maildir::find_special(&self.dir, &["sent", "sent-mail"])
4142                                });
4143                            match sent_dir {
4144                                Some(sent) => {
4145                                    let flags = maildir::Flags {
4146                                        seen: true,
4147                                        ..Default::default()
4148                                    };
4149                                    match maildir::deliver(&sent, final_text.as_bytes(), flags) {
4150                                        Ok(_) => note += ", copy in Sent",
4151                                        Err(_) => note += ", Fcc to Sent failed",
4152                                    }
4153                                }
4154                                None => note += " (no Sent maildir, no copy kept)",
4155                            }
4156                        }
4157                    }
4158                }
4159                // mutt's toggle-unlink: files marked to go, go.
4160                if let Ok(full) = draft_full(&compose_state) {
4161                    for a in compose::extract_attachments(&full).1 {
4162                        if a.unlink {
4163                            let _ = std::fs::remove_file(&a.path);
4164                        }
4165                    }
4166                }
4167                let _ = std::fs::remove_file(&compose_state.path);
4168                if let Some(src) = &compose_state.recall_source {
4169                    let _ = std::fs::remove_file(src);
4170                }
4171                match fcc_to_server {
4172                    Some(body) => self.fcc_to_sent(note, body),
4173                    None => self.note(note),
4174                }
4175            }
4176            Err(err) => {
4177                self.error(format!("send failed: {err:#}"));
4178                self.hand_back(compose_state);
4179            }
4180        }
4181    }
4182
4183    /// The Fcc of a sent message, into the account's Sent on the
4184    /// server. The send has happened; `sent` is what to say about it,
4185    /// and the copy's fate follows it onto the message line.
4186    fn fcc_to_sent(&mut self, sent: String, body: Vec<u8>) {
4187        self.send_then(
4188            Job::Append {
4189                mailbox: None,
4190                flags: maildir::Flags {
4191                    seen: true,
4192                    ..Default::default()
4193                },
4194                body,
4195            },
4196            false,
4197            Box::new(move |session, done| match done {
4198                Ok(Done::Folder(folder)) => session.note(format!("{sent}, copy in {folder}")),
4199                Ok(_) => session.note(format!("{sent}, copy in Sent")),
4200                Err(err) => session.error(format!("{sent}, Fcc to Sent failed: {err:#}")),
4201            }),
4202        );
4203    }
4204
4205    /// neomutt's attachment reminder: does the body mention one when
4206    /// nothing is attached? Quoted lines and anything below a `-- `
4207    /// signature do not count, so a reply to "see attached" and a
4208    /// signature naming one are not false alarms.
4209    pub fn attachment_forgotten(&self, raw: &str, compose: &Compose) -> bool {
4210        if self.config.mail.abort_noattach.as_deref().unwrap_or("no") == "no" {
4211            return false;
4212        }
4213        if compose.attach.is_some() || !compose::extract_attachments(raw).1.is_empty() {
4214            return false;
4215        }
4216        let body = raw.split_once("\n\n").map_or("", |(_, body)| body);
4217        for line in body.lines() {
4218            if line.trim_end() == "--" || line == "-- " {
4219                break;
4220            }
4221            if self.quote_re.is_match(line) {
4222                continue;
4223            }
4224            if self.attach_re.is_match(line) {
4225                return true;
4226            }
4227        }
4228        false
4229    }
4230
4231    /// Assemble the outgoing message from a finalized draft: `files`
4232    /// and the forwarded `original` first turn it into multipart/mixed,
4233    /// then the chosen PGP treatment wraps whatever entity resulted.
4234    pub fn secure_message(
4235        &self,
4236        security: Security,
4237        text: String,
4238        files: &[compose::Attachment],
4239        original: Option<&[u8]>,
4240    ) -> Result<String> {
4241        let cfg = &self.config.pgp;
4242        // Encrypt to every recipient plus the sender, so the Fcc copy
4243        // stays readable.
4244        let recipients = |text: &str| -> Result<Vec<String>> {
4245            let (mut rcpts, _) = compose::smtp_envelope(text)?;
4246            if let Some(from) = compose::from_address(text) {
4247                rcpts.push(from);
4248            }
4249            // mutt's crypt-hook: a recipient with a key of its own is
4250            // encrypted to that key id, not to its address.
4251            for r in &mut rcpts {
4252                if let Some(key) = self.crypt_key_for(r) {
4253                    *r = key;
4254                }
4255            }
4256            rcpts.sort();
4257            rcpts.dedup();
4258            Ok(rcpts)
4259        };
4260        let flowed = self.config.mail.text_flowed;
4261        if files.is_empty() && original.is_none() {
4262            return match security {
4263                // No MIME wrapper at all, so $text_flowed has to
4264                // declare the body itself.
4265                Security::None if flowed => Ok(compose::flow_plain(&text)),
4266                Security::None => Ok(text),
4267                Security::Sign => pgp::sign_message(cfg, &text, flowed),
4268                Security::Encrypt | Security::Both => pgp::encrypt_message(
4269                    cfg,
4270                    &recipients(&text)?,
4271                    security == Security::Both,
4272                    &text,
4273                    flowed,
4274                ),
4275            };
4276        }
4277        let (head, body) = text.split_once("\n\n").unwrap_or((text.trim_end(), ""));
4278        let entity = compose::mixed_entity(body, files, original, flowed)?;
4279        match security {
4280            Security::None => Ok(format!("{}\nMIME-Version: 1.0\n{entity}", head.trim_end())),
4281            Security::Sign => pgp::sign_entity(cfg, head, entity.as_bytes()),
4282            Security::Encrypt | Security::Both => pgp::encrypt_entity(
4283                cfg,
4284                &recipients(&text)?,
4285                security == Security::Both,
4286                head,
4287                entity.as_bytes(),
4288            ),
4289        }
4290    }
4291
4292    pub fn postponed_dir(&self) -> Option<PathBuf> {
4293        self.config
4294            .mail
4295            .postponed
4296            .as_deref()
4297            .map(expand_tilde)
4298            .filter(|p| p.join("cur").is_dir())
4299            .or_else(|| {
4300                maildir::find_special(&self.dir, &["drafts", "postponed", "rmut-postponed"])
4301            })
4302    }
4303
4304    pub fn has_postponed(&self) -> bool {
4305        self.postponed_dir()
4306            .and_then(|d| maildir::scan(&d).ok())
4307            .is_some_and(|files| !files.is_empty())
4308    }
4309
4310    /// mutt's postpone: the draft goes to the postponed maildir,
4311    /// headers and all, ready for a recall.
4312    pub fn postpone_draft(&mut self, compose_state: Compose) {
4313        let target = match self.postponed_dir() {
4314            Some(d) => Ok(d),
4315            None => {
4316                let d = self.dir.join(".rmut-postponed");
4317                maildir::create(&d).map(|()| d)
4318            }
4319        };
4320        let result = target.and_then(|dir| {
4321            // The full message, headers included, so the recall (and
4322            // the postponed picker's subject) sees them.
4323            let bytes = draft_full(&compose_state)?.into_bytes();
4324            let flags = maildir::Flags {
4325                draft: true,
4326                seen: true,
4327                ..Default::default()
4328            };
4329            maildir::deliver(&dir, &bytes, flags)
4330        });
4331        match result {
4332            Ok(path) => {
4333                let _ = std::fs::remove_file(&compose_state.path);
4334                self.note(format!("postponed to {}", path.display()));
4335            }
4336            Err(err) => {
4337                self.error(format!(
4338                    "postpone failed: {err:#}; draft at {}",
4339                    compose_state.path.display()
4340                ));
4341            }
4342        }
4343    }
4344
4345    /// A postponed draft read back into a compose state, for the
4346    /// front end to hand to the editor. None when it cannot be read.
4347    pub fn recall_file(&mut self, source: PathBuf) -> Option<Compose> {
4348        let result = std::fs::read_to_string(&source)
4349            .map_err(anyhow::Error::from)
4350            .and_then(|content| self.stage_draft(&content));
4351        match result {
4352            Ok((path, hidden_head)) => Some(Compose {
4353                path,
4354                recall_source: Some(source),
4355                security: self.default_security(),
4356                attach: None,
4357                hidden_head,
4358                fcc: None,
4359            }),
4360            Err(err) => {
4361                self.error(format!("cannot recall: {err:#}"));
4362                None
4363            }
4364        }
4365    }
4366
4367    /// The draft's header block, wherever it currently lives.
4368    pub fn draft_head(&self) -> String {
4369        let Some(c) = &self.draft else {
4370            return String::new();
4371        };
4372        match &c.hidden_head {
4373            Some(head) => head.clone(),
4374            None => {
4375                let text = std::fs::read_to_string(&c.path).unwrap_or_default();
4376                match text.split_once("\n\n") {
4377                    Some((head, _)) => head.to_string(),
4378                    None => text.trim_end().to_string(),
4379                }
4380            }
4381        }
4382    }
4383
4384    /// Rewrite the draft's header block in place (hidden or in-file).
4385    pub fn edit_draft_head(&mut self, f: impl Fn(&str) -> String) {
4386        let Some(c) = &mut self.draft else {
4387            return;
4388        };
4389        match &mut c.hidden_head {
4390            Some(head) => *head = f(head),
4391            None => {
4392                if let Ok(text) = std::fs::read_to_string(&c.path) {
4393                    let (head, body) = text.split_once("\n\n").unwrap_or((text.trim_end(), ""));
4394                    let _ = std::fs::write(&c.path, format!("{}\n\n{body}", f(head)));
4395                }
4396            }
4397        }
4398    }
4399
4400    /// Replace (or add, or with an empty value drop) one header.
4401    pub fn set_draft_header(&mut self, name: &str, value: &str) {
4402        let value = value.trim().to_string();
4403        let name = name.to_string();
4404        self.edit_draft_head(|head| {
4405            let mut lines: Vec<&str> = head
4406                .lines()
4407                .filter(|l| {
4408                    !l.split_once(':')
4409                        .is_some_and(|(k, _)| k.trim().eq_ignore_ascii_case(&name))
4410                })
4411                .collect();
4412            let added = format!("{name}: {value}");
4413            if !value.is_empty() {
4414                lines.push(&added);
4415            }
4416            lines.join("\n")
4417        });
4418    }
4419
4420    pub fn draft_header(&self, name: &str) -> String {
4421        header_value(&self.draft_head(), name).unwrap_or_default()
4422    }
4423
4424    /// Send the draft in hand. It may need a question answered
4425    /// first, and if it cannot go the draft stays in hand with a
4426    /// request to put it back on screen.
4427    pub fn send_draft(&mut self) -> Option<Ask> {
4428        let compose_state = self.draft.take()?;
4429        let raw = match draft_full(&compose_state) {
4430            Ok(r) => r,
4431            Err(err) => {
4432                self.error(format!("cannot read draft: {err}"));
4433                return None;
4434            }
4435        };
4436        // neomutt's $abort_noattach: the body says "attached" and
4437        // nothing is. Asked once per draft; an answered draft sends.
4438        if !mem::take(&mut self.attach_confirmed) && self.attachment_forgotten(&raw, &compose_state)
4439        {
4440            self.draft = Some(compose_state);
4441            match self.config.mail.abort_noattach.as_deref() {
4442                // neomutt's "yes" aborts outright: attach the file,
4443                // or take the word out of the body.
4444                Some("yes") => {
4445                    self.error("no attachment: not sent (abort_noattach); a attaches one");
4446                    self.requests.push(Request::ShowDraft);
4447                    return None;
4448                }
4449                _ => {
4450                    return Some(Ask::Key {
4451                        label:
4452                            "The body mentions an attachment and none is attached. Send? (y/n): "
4453                                .into(),
4454                        what: AskKind::NoAttach,
4455                    });
4456                }
4457            }
4458        }
4459        // mutt's fcc-hook, evaluated on the draft as it stands after
4460        // the editor; an Fcc picked in the menu still wins.
4461        let draft_path = self
4462            .draft
4463            .as_ref()
4464            .map(|c| c.path.clone())
4465            .unwrap_or_default();
4466        let hook_fcc = self.fcc_hook_target(&raw, &draft_path);
4467        let (raw, files) = compose::extract_attachments(&raw);
4468        let host = maildir::hostname();
4469        let from = self
4470            .current_identity(&[])
4471            .from_line()
4472            .unwrap_or_else(|| default_from(&host));
4473        // mutt's $hostname overrides the Message-ID host.
4474        let msg_host = self
4475            .config
4476            .mail
4477            .hostname
4478            .clone()
4479            .filter(|h| !h.trim().is_empty())
4480            .unwrap_or(host);
4481        let final_text = match compose::finalize_with(
4482            &raw,
4483            &from,
4484            &compose::make_message_id(&msg_host),
4485            &compose::rfc2822_now(),
4486            self.config.mail.user_agent.unwrap_or(false),
4487        ) {
4488            Ok(t) => t,
4489            Err(err) => {
4490                self.error(format!("{err}; press e to edit"));
4491                self.hand_back(compose_state);
4492                return None;
4493            }
4494        };
4495        let original = match &compose_state.attach {
4496            Some(path) => match std::fs::read(path) {
4497                Ok(bytes) => Some(bytes),
4498                Err(err) => {
4499                    self.error(format!("cannot attach the original: {err}"));
4500                    self.hand_back(compose_state);
4501                    return None;
4502                }
4503            },
4504            None => None,
4505        };
4506        let final_text = match self.secure_message(
4507            compose_state.security,
4508            final_text,
4509            &files,
4510            original.as_deref(),
4511        ) {
4512            Ok(t) => t,
4513            Err(err) => {
4514                self.error(format!("{err:#}; e edits, s changes security"));
4515                self.hand_back(compose_state);
4516                return None;
4517            }
4518        };
4519        // The menu's Fcc wins, then any fcc-hook; empty means keep no
4520        // copy, and $copy = no makes that the default.
4521        let held = Held {
4522            text: final_text,
4523            fcc: compose_state.fcc.clone().or(hook_fcc),
4524            label: {
4525                let head = raw.split_once("\n\n").map_or(raw.as_str(), |(h, _)| h);
4526                header_value(head, "Subject")
4527                    .filter(|s| !s.trim().is_empty())
4528                    .or_else(|| header_value(head, "To"))
4529                    .unwrap_or_else(|| "message".into())
4530            },
4531            state: compose_state,
4532            due: Instant::now() + Duration::from_secs(self.config.mail.undo_send),
4533        };
4534        // $undo_send: the message waits, and z takes it back.
4535        if self.config.mail.undo_send > 0 {
4536            self.note(format!(
4537                "sending {} in {}s (z cancels)",
4538                held.label, self.config.mail.undo_send
4539            ));
4540            self.hold_send(held);
4541            return None;
4542        }
4543        self.deliver(held);
4544        None
4545    }
4546
4547    /// The attachment reminder is answered for this draft: the next
4548    /// send goes through without asking again.
4549    fn confirm_attachment(&mut self) {
4550        self.attach_confirmed = true;
4551    }
4552
4553    /// A draft that could not go: back in hand, and back on screen.
4554    fn hand_back(&mut self, draft: Compose) {
4555        self.draft = Some(draft);
4556        self.requests.push(Request::ShowDraft);
4557    }
4558
4559    /// The draft in hand, for a front end showing it.
4560    pub fn draft(&self) -> Option<&Compose> {
4561        self.draft.as_ref()
4562    }
4563
4564    pub fn draft_mut(&mut self) -> Option<&mut Compose> {
4565        self.draft.as_mut()
4566    }
4567
4568    /// Take it out of the session's hands (the front end is about to
4569    /// edit it, postpone it, or throw it away).
4570    pub fn take_draft(&mut self) -> Option<Compose> {
4571        self.draft.take()
4572    }
4573
4574    /// Put one back, after an editor has been over it.
4575    /// The editor has come back: the draft is in hand. mutt's
4576    /// $abort_unmodified drops it instead when the first pass changed
4577    /// nothing, which is what an editor quit with :q looks like from
4578    /// here. A re-edit from the compose menu is not a first pass and
4579    /// is never dropped, so a deliberate second look costs nothing.
4580    pub fn set_draft(&mut self, draft: Compose) {
4581        if let Some((path, staged)) = self.staged.take()
4582            && self.config.mail.abort_unmodified.unwrap_or(true)
4583            && path == draft.path
4584            && std::fs::read_to_string(&draft.path).is_ok_and(|now| now == staged)
4585        {
4586            let _ = std::fs::remove_file(&draft.path);
4587            self.draft = None;
4588            self.error("aborted unmodified message");
4589            return;
4590        }
4591        self.draft = Some(draft);
4592    }
4593
4594    /// The submitted d / ctrl+t edit: rewrite the k-th Attach: line
4595    /// with the new description or content-type.
4596    /// The submitted description or content-type for the k-th
4597    /// `Attach:` line.
4598    pub fn set_attach_field(&mut self, k: usize, input: &str, is_type: bool) {
4599        let value = input.trim().to_string();
4600        self.edit_draft_head(|head| {
4601            let mut seen = 0usize;
4602            head.lines()
4603                .map(|l| {
4604                    let is_attach = l
4605                        .split_once(':')
4606                        .is_some_and(|(key, _)| key.trim().eq_ignore_ascii_case("attach"));
4607                    if is_attach {
4608                        // Count like extract_attachments (empty-value
4609                        // lines don't), so k matches the menu entry.
4610                        let (_, mut atts) = compose::extract_attachments(l);
4611                        if let Some(mut a) = atts.pop() {
4612                            let idx = seen;
4613                            seen += 1;
4614                            if idx == k {
4615                                let new = (!value.is_empty()).then(|| value.clone());
4616                                if is_type {
4617                                    a.mime = new;
4618                                } else {
4619                                    a.description = new;
4620                                }
4621                                return compose::attach_line(&a);
4622                            }
4623                        }
4624                    }
4625                    l.to_string()
4626                })
4627                .collect::<Vec<_>>()
4628                .join("\n")
4629        });
4630    }
4631
4632    /// D in the compose menu: drop the selected Attach: line (the
4633    /// body and a forwarded original cannot be detached).
4634    /// Drop the `Attach:` line the menu's `sel`-th row stands for.
4635    /// The k of the Attach: file a compose-menu row names, or an
4636    /// error when the row is the body or the forwarded original.
4637    pub fn attach_index(&mut self, sel: usize) -> Option<usize> {
4638        let fixed = 1 + usize::from(self.draft().is_some_and(|c| c.attach.is_some()));
4639        if sel < fixed {
4640            self.error("only Attach: files can be changed here");
4641            return None;
4642        }
4643        Some(sel - fixed)
4644    }
4645
4646    /// The draft's Attach: files as they stand.
4647    pub fn attachments(&self) -> Vec<compose::Attachment> {
4648        self.draft()
4649            .and_then(|c| draft_full(c).ok())
4650            .map(|full| compose::extract_attachments(&full).1)
4651            .unwrap_or_default()
4652    }
4653
4654    /// Rewrite the k-th Attach: line through `f`.
4655    pub fn edit_attachment(&mut self, k: usize, f: impl Fn(&mut compose::Attachment)) {
4656        self.edit_draft_head(|head| {
4657            let mut seen = 0usize;
4658            head.lines()
4659                .map(|l| {
4660                    let is_attach = l
4661                        .split_once(':')
4662                        .is_some_and(|(key, _)| key.trim().eq_ignore_ascii_case("attach"));
4663                    if is_attach {
4664                        let (_, mut atts) = compose::extract_attachments(l);
4665                        if let Some(mut a) = atts.pop() {
4666                            let idx = seen;
4667                            seen += 1;
4668                            if idx == k {
4669                                f(&mut a);
4670                                return compose::attach_line(&a);
4671                            }
4672                        }
4673                    }
4674                    l.to_string()
4675                })
4676                .collect::<Vec<_>>()
4677                .join("\n")
4678        });
4679    }
4680
4681    /// mutt's toggle-unlink (u): the file goes once the message has
4682    /// been sent.
4683    pub fn toggle_unlink(&mut self, sel: usize) {
4684        let Some(k) = self.attach_index(sel) else {
4685            return;
4686        };
4687        self.edit_attachment(k, |a| a.unlink = !a.unlink);
4688        let on = self.attachments().get(k).is_some_and(|a| a.unlink);
4689        self.note(if on {
4690            "the file will be deleted after sending"
4691        } else {
4692            "the file will be kept after sending"
4693        });
4694    }
4695
4696    /// mutt's toggle-disposition (Ctrl+D): inline or attachment.
4697    pub fn toggle_disposition(&mut self, sel: usize) {
4698        let Some(k) = self.attach_index(sel) else {
4699            return;
4700        };
4701        self.edit_attachment(k, |a| a.inline = !a.inline);
4702    }
4703
4704    /// mutt's move-up / move-down: the k-th Attach: line swaps with
4705    /// its neighbour. Whether anything moved.
4706    pub fn move_attachment(&mut self, sel: usize, up: bool) -> bool {
4707        let Some(k) = self.attach_index(sel) else {
4708            return false;
4709        };
4710        let n = self.attachments().len();
4711        let other = if up {
4712            k.checked_sub(1)
4713        } else {
4714            (k + 1 < n).then_some(k + 1)
4715        };
4716        let Some(other) = other else { return false };
4717        self.edit_draft_head(|head| {
4718            let mut lines: Vec<String> = head.lines().map(String::from).collect();
4719            let attach_rows: Vec<usize> = lines
4720                .iter()
4721                .enumerate()
4722                .filter(|(_, l)| {
4723                    l.split_once(':')
4724                        .is_some_and(|(key, _)| key.trim().eq_ignore_ascii_case("attach"))
4725                })
4726                .map(|(i, _)| i)
4727                .collect();
4728            if let (Some(&a), Some(&b)) = (attach_rows.get(k), attach_rows.get(other)) {
4729                lines.swap(a, b);
4730            }
4731            lines.join("\n")
4732        });
4733        true
4734    }
4735
4736    /// One more Attach: line on the draft, wherever its head lives.
4737    fn push_attach_line(&mut self, line: &str) -> std::io::Result<()> {
4738        let Some(c) = self.draft_mut() else {
4739            return Ok(());
4740        };
4741        match &mut c.hidden_head {
4742            Some(head) => {
4743                *head = format!("{}\n{line}", head.trim_end());
4744                Ok(())
4745            }
4746            None => std::fs::read_to_string(&c.path).and_then(|text| {
4747                let updated = match text.split_once("\n\n") {
4748                    Some((head, body)) => format!("{head}\n{line}\n\n{body}"),
4749                    None => format!("{}\n{line}\n", text.trim_end()),
4750                };
4751                std::fs::write(&c.path, updated)
4752            }),
4753        }
4754    }
4755
4756    /// mutt's attach-message (A): mutt opens a mailbox and has you
4757    /// tag messages in it; rmut takes the tagged messages of the open
4758    /// mailbox (the one under the cursor when none is tagged), each
4759    /// as a message/rfc822 part, inline as mutt makes them. A
4760    /// header-only IMAP cache file is refused: open it first.
4761    pub fn attach_messages(&mut self) {
4762        if self.draft().is_none() {
4763            return;
4764        }
4765        let tagged = self.msgs.iter().any(|m| m.env.tagged);
4766        let paths = self.target_paths(tagged);
4767        let mut attached = 0usize;
4768        for path in paths {
4769            if remote::is_partial(&path) {
4770                self.error(format!(
4771                    "{} is not fetched yet: open it first",
4772                    path.file_name().and_then(|n| n.to_str()).unwrap_or("?")
4773                ));
4774                continue;
4775            }
4776            let mut a = compose::Attachment::of(path);
4777            a.mime = Some("message/rfc822".into());
4778            a.inline = true;
4779            let line = compose::attach_line(&a);
4780            match self.push_attach_line(&line) {
4781                Ok(()) => attached += 1,
4782                Err(err) => self.error(format!("cannot attach: {err}")),
4783            }
4784        }
4785        if attached > 0 {
4786            self.note(format!("attached {attached} message(s)"));
4787        }
4788        self.requests.push(Request::ShowDraft);
4789    }
4790
4791    /// mutt's new-mime, both halves answered: the file exists (made
4792    /// empty when it did not), is attached under the type given, and
4793    /// goes to the editor.
4794    pub fn new_mime(&mut self, path: &str, mime: &str) {
4795        if mime.is_empty() {
4796            self.requests.push(Request::ShowDraft);
4797            return;
4798        }
4799        if !mime.contains('/') || mime.starts_with('/') || mime.ends_with('/') {
4800            self.error("Content-Type is of the form base/sub");
4801            self.requests.push(Request::ShowDraft);
4802            return;
4803        }
4804        let file = expand_tilde(path);
4805        if !file.exists()
4806            && let Err(err) = std::fs::write(&file, b"")
4807        {
4808            self.error(format!("cannot create {}: {err}", file.display()));
4809            self.requests.push(Request::ShowDraft);
4810            return;
4811        }
4812        let mut a = compose::Attachment::of(file.clone());
4813        a.mime = Some(mime.to_string());
4814        let line = compose::attach_line(&a);
4815        if let Err(err) = self.push_attach_line(&line) {
4816            self.error(format!("cannot attach: {err}"));
4817        } else {
4818            self.requests.push(Request::EditFile(file));
4819        }
4820        self.requests.push(Request::ShowDraft);
4821    }
4822
4823    /// mutt's ispell (i): the command that spell-checks the draft on
4824    /// the real terminal, `$ispell -x FILE`.
4825    pub fn ispell_command(&self) -> Option<String> {
4826        let path = self.draft()?.path.display().to_string();
4827        let ispell = self.config.mail.ispell.as_deref().unwrap_or("ispell");
4828        Some(format!("{ispell} -x '{}'", path.replace('\'', "'\\''")))
4829    }
4830
4831    /// The message as it would go out: headers finalized, attachments
4832    /// and the forwarded original assembled, security applied.
4833    fn outgoing_text(&self, c: &Compose) -> Result<String> {
4834        let raw = draft_full(c).context("reading the draft")?;
4835        let (raw, files) = compose::extract_attachments(&raw);
4836        let host = maildir::hostname();
4837        let from = self
4838            .current_identity(&[])
4839            .from_line()
4840            .unwrap_or_else(|| default_from(&host));
4841        let msg_host = self
4842            .config
4843            .mail
4844            .hostname
4845            .clone()
4846            .filter(|h| !h.trim().is_empty())
4847            .unwrap_or(host);
4848        let text = compose::finalize_with(
4849            &raw,
4850            &from,
4851            &compose::make_message_id(&msg_host),
4852            &compose::rfc2822_now(),
4853            self.config.mail.user_agent.unwrap_or(false),
4854        )
4855        .map_err(|e| anyhow::anyhow!("{e}"))?;
4856        let original = match &c.attach {
4857            Some(path) => Some(std::fs::read(path).context("reading the original")?),
4858            None => None,
4859        };
4860        self.secure_message(c.security, text, &files, original.as_deref())
4861    }
4862
4863    /// mutt's write-fcc (w): the message as it stands into a mailbox
4864    /// (a local maildir, or a folder of the open IMAP account), not
4865    /// sent, the draft untouched.
4866    pub fn write_draft_to(&mut self, mailbox: &str) {
4867        let Some(c) = self.draft() else { return };
4868        let text = match self.outgoing_text(c) {
4869            Ok(t) => t,
4870            Err(err) => {
4871                self.error(format!("cannot write the message: {err:#}"));
4872                return;
4873            }
4874        };
4875        let flags = maildir::Flags {
4876            seen: true,
4877            ..Default::default()
4878        };
4879        match remote::parse_spec(mailbox) {
4880            Some((account, folder)) => {
4881                if !self
4882                    .imap
4883                    .as_ref()
4884                    .is_some_and(|i| i.facts.account.name == account)
4885                {
4886                    self.error("write-fcc to IMAP needs a folder of the open account");
4887                    return;
4888                }
4889                let mailbox = mailbox.to_string();
4890                self.send_then(
4891                    Job::Append {
4892                        mailbox: Some(folder.to_string()),
4893                        flags,
4894                        body: text.into_bytes(),
4895                    },
4896                    false,
4897                    Box::new(move |session, done| match done {
4898                        Ok(_) => session.note(format!("Message written to {mailbox}.")),
4899                        Err(err) => session.error(format!("write failed: {err:#}")),
4900                    }),
4901                );
4902            }
4903            None => {
4904                let dir = expand_tilde(mailbox);
4905                if !dir.join("cur").is_dir() {
4906                    self.error(format!("{mailbox} is not a maildir"));
4907                    return;
4908                }
4909                match maildir::deliver(&dir, text.as_bytes(), flags) {
4910                    Ok(_) => self.note(format!("Message written to {mailbox}.")),
4911                    Err(err) => self.error(format!("write failed: {err:#}")),
4912                }
4913            }
4914        }
4915    }
4916
4917    pub fn detach(&mut self, sel: usize) {
4918        let fixed = 1 + usize::from(self.draft().is_some_and(|c| c.attach.is_some()));
4919        if sel < fixed {
4920            self.error("only Attach: files can be detached");
4921            return;
4922        }
4923        let k = sel - fixed;
4924        self.edit_draft_head(|head| {
4925            let mut seen = 0usize;
4926            head.lines()
4927                .filter(|l| {
4928                    let is_attach = l
4929                        .split_once(':')
4930                        .is_some_and(|(key, _)| key.trim().eq_ignore_ascii_case("attach"));
4931                    if is_attach {
4932                        seen += 1;
4933                        seen - 1 != k
4934                    } else {
4935                        true
4936                    }
4937                })
4938                .collect::<Vec<_>>()
4939                .join("\n")
4940        });
4941    }
4942
4943    /// Add an `Attach:` line to the draft's header block without a
4944    /// trip through the editor (send prompt `a`).
4945    /// A file added to the draft as an `Attach:` line.
4946    pub fn attach_file(&mut self, input: &str) {
4947        let input = input.trim();
4948        if !input.is_empty() {
4949            if !expand_tilde(input).is_file() {
4950                self.error(format!("{input} is not a file"));
4951            } else if let Some(c) = self.draft_mut() {
4952                // Quote paths with spaces the way extract_attachments
4953                // reads them back.
4954                let value = if input.contains(char::is_whitespace) && !input.starts_with('"') {
4955                    format!("\"{input}\"")
4956                } else {
4957                    input.to_string()
4958                };
4959                let result = match &mut c.hidden_head {
4960                    // Withheld headers: the Attach: line joins them.
4961                    Some(head) => {
4962                        *head = format!("{}\nAttach: {value}", head.trim_end());
4963                        Ok(())
4964                    }
4965                    None => std::fs::read_to_string(&c.path).and_then(|text| {
4966                        let updated = match text.split_once("\n\n") {
4967                            Some((head, body)) => format!("{head}\nAttach: {value}\n\n{body}"),
4968                            None => format!("{}\nAttach: {value}\n", text.trim_end()),
4969                        };
4970                        std::fs::write(&c.path, updated)
4971                    }),
4972                };
4973                if let Err(err) = result {
4974                    self.error(format!("cannot attach: {err}"));
4975                }
4976            }
4977        }
4978        self.requests.push(Request::ShowDraft);
4979    }
4980
4981    pub fn error(&mut self, msg: impl Into<String>) {
4982        self.notify(Notice::Error(msg.into()));
4983    }
4984
4985    /// Something worth saying that is not a complaint.
4986    pub fn note(&mut self, msg: impl Into<String>) {
4987        self.notify(Notice::Info(msg.into()));
4988    }
4989
4990    /// Emit, and ring the bell on an error if the user wants one.
4991    /// The bell belongs to the front end, not to the notice: $beep
4992    /// can be set from a command mid-session, so it is read here
4993    /// rather than remembered by the sink.
4994    fn notify(&mut self, notice: Notice) {
4995        if let Notice::Error(text) = &notice {
4996            let keep = self.config.ui.error_history;
4997            self.error_history.push_back(text.clone());
4998            while self.error_history.len() > keep {
4999                self.error_history.pop_front();
5000            }
5001        }
5002        // mutt's $beep_new rings for an arrival the same way $beep
5003        // rings for a complaint, and is off by default.
5004        if (notice.is_error() && self.config.ui.beep)
5005            || (notice.is_new_mail() && self.config.ui.beep_new)
5006        {
5007            use std::io::Write as _;
5008            let mut out = std::io::stdout();
5009            let _ = out.write_all(b"\x07");
5010            let _ = out.flush();
5011        }
5012        self.notices.notice(notice);
5013        self.spoken = true;
5014    }
5015
5016    /// The last thing said, for the message line and for the callers
5017    /// that only speak up when nothing else has.
5018    pub fn notice(&self) -> Option<&Notice> {
5019        self.notices.latest()
5020    }
5021}
5022
5023/// Sort key for subjects: case-insensitive, Re:/Fwd: prefixes stripped.
5024fn subject_key(subject: &str) -> String {
5025    let mut key = subject.trim().to_lowercase();
5026    loop {
5027        let stripped = key
5028            .strip_prefix("re:")
5029            .or_else(|| key.strip_prefix("fwd:"))
5030            .or_else(|| key.strip_prefix("fw:"))
5031            .map(|rest| rest.trim_start().to_string());
5032        match stripped {
5033            Some(next) => key = next,
5034            None => break,
5035        }
5036    }
5037    key
5038}
5039
5040/// "[reverse-]date|from|subject|size|threads" from the config.
5041pub fn parse_sort(spec: &str) -> Option<(SortKey, bool)> {
5042    let (rev, name) = match spec.strip_prefix("reverse-") {
5043        Some(rest) => (true, rest),
5044        None => (false, spec),
5045    };
5046    let key = match name {
5047        "date" | "date-sent" | "date-received" => SortKey::Date,
5048        "from" => SortKey::From,
5049        "subject" => SortKey::Subject,
5050        "size" => SortKey::Size,
5051        "threads" => SortKey::Threads,
5052        "label" => SortKey::Label,
5053        "to" => SortKey::To,
5054        "unsorted" | "mailbox-order" => SortKey::Unsorted,
5055        _ => return None,
5056    };
5057    // Thread sort has no reverse variant.
5058    Some((key, rev && key != SortKey::Threads))
5059}
5060
5061impl pattern::EnvSource for Session {
5062    fn envelope(&self, i: usize) -> Option<&Envelope> {
5063        self.msgs.get(i).map(|m| &m.env)
5064    }
5065}
5066
5067fn dir_mtimes(dir: &Path) -> (Option<SystemTime>, Option<SystemTime>) {
5068    let mtime = |p: PathBuf| p.metadata().and_then(|m| m.modified()).ok();
5069    (mtime(dir.join("new")), mtime(dir.join("cur")))
5070}
5071
5072/// Run the account's password command once per session. OAuth tokens
5073/// expire, so those are fetched fresh for every connection instead.
5074pub fn account_password(account: &Account) -> Result<String> {
5075    if !matches!(account.auth_kind()?, rmut_core::config::AuthKind::Password) {
5076        return account.secret();
5077    }
5078    static CACHE: Mutex<Option<HashMap<String, String>>> = Mutex::new(None);
5079    let mut cache = CACHE.lock().unwrap();
5080    let map = cache.get_or_insert_with(HashMap::new);
5081    if let Some(password) = map.get(&account.name) {
5082        return Ok(password.clone());
5083    }
5084    let password = account.password()?;
5085    map.insert(account.name.clone(), password.clone());
5086    Ok(password)
5087}
5088
5089/// Visit order for a wrapping scan over `n` entries starting after
5090/// (before, when backwards) `sel`: each index paired with a flag set
5091/// once the walk passed the end (start). The starting index comes
5092/// last, so a lone match under the cursor still counts as a wrap.
5093pub fn wrap_order(n: usize, sel: usize, forward: bool) -> Vec<(usize, bool)> {
5094    (1..=n)
5095        .map(|step| {
5096            if forward {
5097                ((sel + step) % n, sel + step >= n)
5098            } else {
5099                ((sel + n - (step % n)) % n, step > sel)
5100            }
5101        })
5102        .collect()
5103}
5104
5105/// A path with a leading `~` expanded, as mutt does everywhere it
5106/// takes one.
5107/// mutt's $sort_browser over browser entries of (spec, new count):
5108/// alpha (the default), count / unread (by the count), date (by the
5109/// maildir's change time; IMAP folders have none and sort first),
5110/// unsorted (as given), with a "reverse-" prefix flipping any. size
5111/// reads as alpha: a maildir's size is not worth a walk.
5112pub fn sort_browser(dirs: &mut [(String, usize)], how: Option<&str>) {
5113    let how = how.unwrap_or("alpha");
5114    let (reverse, key) = match how.strip_prefix("reverse-") {
5115        Some(key) => (true, key),
5116        None => (false, how),
5117    };
5118    match key {
5119        "unsorted" => {}
5120        "count" | "unread" => dirs.sort_by_key(|(_, count)| *count),
5121        "date" => dirs.sort_by_cached_key(|(spec, _)| {
5122            if spec.starts_with("imap:") {
5123                return std::time::SystemTime::UNIX_EPOCH;
5124            }
5125            let dir = expand_tilde(spec);
5126            ["new", "cur"]
5127                .iter()
5128                .filter_map(|sub| std::fs::metadata(dir.join(sub)).ok())
5129                .filter_map(|m| m.modified().ok())
5130                .max()
5131                .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
5132        }),
5133        _ => dirs.sort_by(|a, b| a.0.cmp(&b.0)),
5134    }
5135    if reverse {
5136        dirs.reverse();
5137    }
5138}
5139
5140pub fn expand_tilde(input: &str) -> PathBuf {
5141    if let Some(rest) = input.strip_prefix("~/")
5142        && let Ok(home) = std::env::var("HOME")
5143    {
5144        return Path::new(&home).join(rest);
5145    }
5146    PathBuf::from(input)
5147}
5148
5149pub fn send_via_smtp(account: &Account, text: &str) -> Result<()> {
5150    let from = compose::from_address(text).context("cannot parse the From address")?;
5151    let (rcpts, text) = compose::smtp_envelope(text)?;
5152    anyhow::ensure!(!rcpts.is_empty(), "no recipient addresses");
5153    let password = account_password(account)?;
5154    smtp::send(account, &password, &from, &rcpts, text.as_bytes())
5155}
5156
5157/// Run a shell command with `bytes` on its stdin.
5158pub fn pipe_to(command: &str, bytes: &[u8]) -> Result<()> {
5159    let mut child = Command::new("sh")
5160        .arg("-c")
5161        .arg(command)
5162        .stdin(Stdio::piped())
5163        .stdout(Stdio::null())
5164        .stderr(Stdio::null())
5165        .spawn()
5166        .with_context(|| format!("running {command}"))?;
5167    // A command that ignores stdin and exits (grep -q, printf) can
5168    // close the pipe before we finish writing; that BrokenPipe is not
5169    // a failure, so let the exit status be the verdict.
5170    {
5171        let mut stdin = child.stdin.take().context("no stdin on the child")?;
5172        if let Err(err) = stdin.write_all(bytes)
5173            && err.kind() != std::io::ErrorKind::BrokenPipe
5174        {
5175            return Err(err.into());
5176        }
5177    }
5178    let status = child.wait()?;
5179    anyhow::ensure!(status.success(), "{command} exited with {status}");
5180    Ok(())
5181}
5182
5183/// With `rcpts` the addresses go on the command line (a bounce keeps
5184/// its Resent-To out of -t's reach); otherwise -t reads To/Cc/Bcc.
5185pub fn run_sendmail(
5186    bytes: &[u8],
5187    configured: Option<&str>,
5188    rcpts: Option<&[String]>,
5189) -> Result<()> {
5190    let command = std::env::var("RMUT_SENDMAIL")
5191        .ok()
5192        .or_else(|| configured.map(String::from));
5193    let (prog, mut args) = match command {
5194        Some(v) => {
5195            let mut it = v.split_whitespace().map(String::from);
5196            let p = it.next().context("sendmail command is empty")?;
5197            (p, it.collect::<Vec<_>>())
5198        }
5199        None => {
5200            let p = if Path::new("/usr/sbin/sendmail").exists() {
5201                "/usr/sbin/sendmail".to_string()
5202            } else {
5203                "sendmail".to_string()
5204            };
5205            (p, Vec::new())
5206        }
5207    };
5208    match rcpts {
5209        Some(rcpts) => {
5210            args.push("-oi".into());
5211            args.extend(rcpts.iter().cloned());
5212        }
5213        None => args.extend(["-t".into(), "-oi".into()]),
5214    }
5215    let mut child = Command::new(&prog)
5216        .args(&args)
5217        .stdin(Stdio::piped())
5218        .stdout(Stdio::null())
5219        .stderr(Stdio::null())
5220        .spawn()
5221        .with_context(|| format!("running {prog}"))?;
5222    child
5223        .stdin
5224        .take()
5225        .context("no stdin on sendmail child")?
5226        .write_all(bytes)?;
5227    let status = child.wait()?;
5228    anyhow::ensure!(status.success(), "{prog} exited with {status}");
5229    Ok(())
5230}
5231
5232pub fn default_from(hostname: &str) -> String {
5233    if let Ok(email) = std::env::var("EMAIL") {
5234        return email;
5235    }
5236    let user = std::env::var("USER").unwrap_or_else(|_| "user".into());
5237    format!("{user}@{hostname}")
5238}
5239
5240/// neomutt's $abort_noattach_regex default: the words that make a
5241/// draft look like it should have carried a file.
5242const DEFAULT_ATTACH_KEYWORD: &str = r"\b(attach|attached|attaching|attachment|attachments)\b";
5243
5244/// mutt's $abort_noattach_regex, compiled; a bad one warns and the
5245/// default stands.
5246fn attach_re_from_config(config: &Config, warnings: &mut Vec<String>) -> regex_lite::Regex {
5247    let spec = config
5248        .mail
5249        .attach_keyword
5250        .clone()
5251        .unwrap_or_else(|| DEFAULT_ATTACH_KEYWORD.to_string());
5252    match regex_lite::Regex::new(&format!("(?i){spec}")) {
5253        Ok(re) => re,
5254        Err(err) => {
5255            warnings.push(format!("bad attach_keyword {spec:?}: {err}"));
5256            default_attach_re()
5257        }
5258    }
5259}
5260
5261fn default_attach_re() -> regex_lite::Regex {
5262    regex_lite::Regex::new(&format!("(?i){DEFAULT_ATTACH_KEYWORD}")).expect("the default compiles")
5263}
5264
5265/// mutt's $reply_regexp, compiled; a bad one warns and the default
5266/// stands in.
5267fn reply_re_from_config(config: &Config, warnings: &mut Vec<String>) -> regex_lite::Regex {
5268    match &config.mail.reply_regexp {
5269        Some(spec) => match compose::reply_regexp(spec) {
5270            Ok(re) => re,
5271            Err(err) => {
5272                warnings.push(format!("bad reply_regexp {spec:?}: {err}"));
5273                compose::default_reply_regexp()
5274            }
5275        },
5276        None => compose::default_reply_regexp(),
5277    }
5278}
5279
5280/// mutt's $quote_regexp, compiled; a bad one warns and the default
5281/// stands.
5282fn quote_re_from_config(config: &Config, warnings: &mut Vec<String>) -> regex_lite::Regex {
5283    match &config.pager.quote_regexp {
5284        Some(spec) => match regex_lite::Regex::new(spec) {
5285            Ok(re) => re,
5286            Err(err) => {
5287                warnings.push(format!("bad quote_regexp {spec:?}: {err}"));
5288                default_quote_re()
5289            }
5290        },
5291        None => default_quote_re(),
5292    }
5293}
5294
5295/// What the pager needs from the config: the header rules and the
5296/// auto_view filter table.
5297fn display_from_config(config: &Config) -> message::Display {
5298    // [pager] ignore/unignore/hdr_order override the classic
5299    // five-header view field by field; entries are lowercased and
5300    // hdr_order accepts mutt's trailing colons.
5301    let mut rules = message::HeaderRules::default();
5302    let clean = |list: &Vec<String>| {
5303        list.iter()
5304            .map(|n| n.trim_end_matches(':').to_lowercase())
5305            .collect::<Vec<_>>()
5306    };
5307    if let Some(list) = &config.pager.ignore {
5308        rules.ignore = clean(list);
5309    }
5310    if let Some(list) = &config.pager.unignore {
5311        rules.unignore = clean(list);
5312    }
5313    if let Some(list) = &config.pager.hdr_order {
5314        rules.order = clean(list);
5315    }
5316    // auto_view types with no command of their own take one from
5317    // mailcap, where mutt looks too; a type with no copiousoutput
5318    // entry there simply does not autoview, and shows as an
5319    // attachment stub.
5320    let mut filters: HashMap<String, String> = HashMap::new();
5321    let mut mailcap: Option<Vec<rmut_core::mailcap::Entry>> = None;
5322    for (mimetype, command) in &config.filters {
5323        let mimetype = mimetype.to_lowercase();
5324        if !command.trim().is_empty() {
5325            filters.insert(mimetype, command.clone());
5326            continue;
5327        }
5328        let entries = mailcap.get_or_insert_with(rmut_core::mailcap::load);
5329        if let Some(command) = rmut_core::mailcap::command_for(entries, &mimetype) {
5330            filters.insert(mimetype, command);
5331        }
5332    }
5333    message::Display {
5334        filters,
5335        rules,
5336        reflow: config.pager.reflow_text.unwrap_or(true),
5337        alternative_order: config
5338            .pager
5339            .alternative_order
5340            .iter()
5341            .map(|t| t.to_lowercase())
5342            .collect(),
5343        html_to_text: config.pager.html.as_deref() != Some("raw"),
5344    }
5345}
5346
5347/// Compile a hook table, dropping (with a warning) any entry whose
5348/// pattern does not parse or whose value is empty.
5349fn compile_hooks<'a>(
5350    what: &str,
5351    entries: impl Iterator<Item = (&'a String, &'a String)>,
5352    warnings: &mut Vec<String>,
5353) -> Vec<Hook> {
5354    let mut out = Vec::new();
5355    for (spec, value) in entries {
5356        if value.trim().is_empty() {
5357            warnings.push(format!("{what} {spec:?} has nothing to do"));
5358            continue;
5359        }
5360        match pattern::parse(spec) {
5361            Ok(patterns) => out.push(Hook {
5362                patterns,
5363                value: value.clone(),
5364            }),
5365            Err(err) => warnings.push(format!("bad {what} pattern {spec:?}: {err}")),
5366        }
5367    }
5368    out
5369}
5370
5371/// First value of a (single-line) header in a draft head block.
5372pub fn header_value(head: &str, name: &str) -> Option<String> {
5373    head.lines().find_map(|l| {
5374        let (k, v) = l.split_once(':')?;
5375        k.trim()
5376            .eq_ignore_ascii_case(name)
5377            .then(|| v.trim().to_string())
5378    })
5379}
5380
5381/// The draft as a full message: the file as edited, with any withheld
5382/// header block put back in front.
5383pub fn draft_full(c: &Compose) -> std::io::Result<String> {
5384    let text = std::fs::read_to_string(&c.path)?;
5385    Ok(match &c.hidden_head {
5386        Some(head) => format!("{}\n\n{}", head.trim_end(), text),
5387        None => text,
5388    })
5389}
5390
5391/// mutt's $quote_regexp default.
5392pub fn default_quote_re() -> regex_lite::Regex {
5393    regex_lite::Regex::new(r"^([ \t]*[|>:}#])+").expect("default quote_regexp compiles")
5394}
5395
5396pub fn write_draft(text: &str) -> Result<PathBuf> {
5397    use std::sync::atomic::{AtomicUsize, Ordering};
5398    static COUNTER: AtomicUsize = AtomicUsize::new(0);
5399    let path = std::env::temp_dir().join(format!(
5400        "rmut-draft-{}-{}.eml",
5401        std::process::id(),
5402        COUNTER.fetch_add(1, Ordering::Relaxed),
5403    ));
5404    std::fs::write(&path, text).with_context(|| format!("writing {}", path.display()))?;
5405    Ok(path)
5406}