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