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