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