Skip to main content

rmut_session/
lib.rs

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