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