Skip to main content

rmut_session/
ask.rs

1//! What a session needs from whoever is driving it.
2//!
3//! An operation that cannot finish without an answer stops and says
4//! what it needs: a mailbox name, a command, yes or no. The front end
5//! collects the answer however it likes (mutt's message line, a
6//! dialog, a test calling [`Session::answer`] straight away) and hands
7//! it back with the [`AskKind`] it came with. Answering can lead to
8//! another question, which is how a two-step operation like bounce
9//! (to whom, then really?) works without the session knowing anything
10//! about prompts.
11//!
12//! The same goes the other way for [`Request`]: things only the front
13//! end can do, because it owns the terminal, or the window, or in the
14//! case of a library caller nothing at all.
15
16use crate::{Msg, Security, Session, SortKey, mailbox_exists};
17
18/// A question waiting on an answer.
19pub enum Ask {
20    /// A line of text.
21    Line {
22        /// What goes in front of the cursor.
23        label: String,
24        /// What the line starts out holding.
25        prefill: String,
26        /// What sort of answer it is, for a front end that offers
27        /// history or completion.
28        wants: Wants,
29        what: AskKind,
30    },
31    /// One keystroke: a yes/no, or a small menu.
32    Key { label: String, what: AskKind },
33}
34
35/// What a line answer is, so a front end can help the user give one.
36#[derive(Clone, Copy, PartialEq, Eq)]
37pub enum Wants {
38    Mailbox,
39    Pattern,
40    Address,
41    Command,
42    Other,
43}
44
45/// The keystroke answering a [`Ask::Key`]. `Other` is anything the
46/// question does not recognise, which usually calls it off.
47#[derive(Clone, Copy, PartialEq, Eq)]
48pub enum Key {
49    Char(char),
50    Enter,
51    Other,
52}
53
54/// An answer on its way back in.
55pub enum Answer<'a> {
56    Line(&'a str),
57    Key(Key),
58}
59
60/// Which question is being answered, and everything the operation was
61/// holding when it stopped to ask. The front end carries it back
62/// untouched, so nothing about a half-done operation lives in the
63/// front end.
64#[derive(Clone)]
65pub enum AskKind {
66    /// mutt's limit: show only the messages matching a pattern.
67    Limit,
68    /// mutt's mark-message: the stroke that will jump to this
69    /// Message-ID.
70    MarkMessage {
71        msg_id: String,
72    },
73    /// mutt's list-action: which RFC 2369 action, out of what the
74    /// message offers (None where it offers nothing).
75    ListAction {
76        actions: Vec<(&'static str, Option<String>)>,
77    },
78    /// An index search; `back` is mutt's search-reverse.
79    Search {
80        back: bool,
81    },
82    /// A mark applied to every message matching a pattern.
83    Pattern {
84        op: PatternOp,
85    },
86    /// Where to copy the messages, and whether the originals are
87    /// marked deleted afterwards (mutt's save).
88    CopyTo {
89        delete: bool,
90        tagged: bool,
91        /// mutt's decode-save / decode-copy: deliver the decoded
92        /// message rather than the raw bytes.
93        decode: bool,
94    },
95    Pipe {
96        tagged: bool,
97    },
98    /// mutt's $print: print this message? `default_yes` is what Enter
99    /// takes, from ask-yes or ask-no (rmut's default, as in mutt).
100    PrintConfirm {
101        tagged: bool,
102        default_yes: bool,
103    },
104    BounceTo {
105        tagged: bool,
106    },
107    BounceConfirm {
108        to: String,
109        tagged: bool,
110    },
111    /// Purge the deleted messages? `quit` leaves afterwards.
112    Purge {
113        quit: bool,
114    },
115    /// mutt's sort menu: one key picks the order.
116    Sort,
117    /// The nick for a create-alias; the address came from the message.
118    AliasNick {
119        addr: String,
120    },
121    /// mutt's edit-label: the X-Label for the message or the tagged
122    /// set. Empty clears it.
123    EditLabel {
124        tagged: bool,
125    },
126    /// mutt's $reply_to (ask-yes): reply to the Reply-To address?
127    ReplyTo,
128    /// Who the draft goes to.
129    ComposeTo,
130    /// mutt's $askcc / $askbcc: who else gets a copy.
131    ComposeCc,
132    ComposeBcc,
133    /// What it is about.
134    ComposeSubject,
135    /// mutt's $abort_nosubject: no subject, abort? `default_yes` is
136    /// what Enter takes, from ask-yes (mutt's default) or ask-no.
137    NoSubject {
138        default_yes: bool,
139    },
140    /// mutt's $include: quote the original in the reply? `default_yes`
141    /// is what Enter takes, from ask-yes or ask-no.
142    IncludeReply {
143        default_yes: bool,
144    },
145    /// mime_forward = "ask": forward the original as an attachment?
146    ForwardAttach,
147    /// $abort_noattach = ask: the body mentions an attachment and
148    /// none is attached. Send it anyway?
149    NoAttach,
150    /// A header of the draft in hand, edited from the compose menu.
151    EditHeader {
152        name: String,
153    },
154    /// Where the sent copy goes (empty keeps none).
155    EditFcc,
156    /// A file to attach.
157    AttachFile,
158    /// The description or the content-type of the k-th attachment.
159    AttachField {
160        k: usize,
161        is_type: bool,
162    },
163    /// mutt's rename-attachment: the name the k-th file goes out as.
164    RenameAttachment {
165        k: usize,
166    },
167    /// mutt's new-mime, first half: the file to make.
168    NewMimeFile,
169    /// mutt's new-mime, second half: its Content-Type.
170    NewMimeType {
171        path: String,
172    },
173    /// mutt's write-fcc: the mailbox the message is written to, as
174    /// it stands, without sending.
175    WriteFcc,
176    /// mutt's compose menu `p`: sign, encrypt, both, or neither.
177    Security,
178    /// Leaving the compose menu: postpone the draft, or throw it away?
179    PostponeAsk {
180        default_yes: bool,
181    },
182    /// mutt's `!`: a shell command to run with the display stood down.
183    Shell,
184    /// mutt's $quit: leave the mailbox and the program?
185    QuitConfirm,
186    /// mutt's $confirmappend: the target mailbox exists; add to it?
187    AppendConfirm {
188        input: String,
189        delete: bool,
190        tagged: bool,
191        decode: bool,
192    },
193}
194
195/// The pattern operations, mutt's D/U/T/Ctrl+T.
196#[derive(Clone, Copy, PartialEq, Eq)]
197pub enum PatternOp {
198    Delete,
199    Undelete,
200    Tag,
201    Untag,
202}
203
204impl PatternOp {
205    /// The word the undo step and the note use.
206    fn verb(self) -> &'static str {
207        match self {
208            PatternOp::Delete => "deleted",
209            PatternOp::Undelete => "undeleted",
210            PatternOp::Tag => "tagged",
211            PatternOp::Untag => "untagged",
212        }
213    }
214
215    /// The word the question uses.
216    fn label(self) -> &'static str {
217        match self {
218            PatternOp::Delete => "Delete",
219            PatternOp::Undelete => "Undelete",
220            PatternOp::Tag => "Tag",
221            PatternOp::Untag => "Untag",
222        }
223    }
224
225    fn apply(self, m: &mut Msg, flag_safe: bool) {
226        match self {
227            PatternOp::Delete => {
228                // mutt's delete-pattern sets the flag and nothing
229                // else: $flag_safe applies, $delete_untag does not.
230                let safe = flag_safe && m.env.file.flags.flagged;
231                if !m.env.file.flags.deleted && !safe {
232                    m.env.file.flags.deleted = true;
233                    m.dirty = true;
234                }
235            }
236            PatternOp::Undelete => {
237                if m.env.file.flags.deleted {
238                    m.env.file.flags.deleted = false;
239                    m.dirty = true;
240                }
241            }
242            PatternOp::Tag => m.env.tagged = true,
243            PatternOp::Untag => m.env.tagged = false,
244        }
245    }
246}
247
248/// Something only the front end can do, handed back for it to honour
249/// or refuse.
250pub enum Request {
251    /// Nothing is pending; the session is done being driven.
252    Quit,
253    /// The config moved: whatever the front end derives from it (the
254    /// colours, the key tables) wants rebuilding.
255    ConfigChanged,
256    /// A command line the session does not handle, because it binds a
257    /// key, queues one, or runs a function.
258    Command(rmut_core::command::Command),
259    /// The mailbox counts moved: a front end showing them (a
260    /// sidebar) wants to redraw.
261    MailboxesChanged,
262    /// A message is ready to read: show it however messages are
263    /// shown (mutt puts it in the pager).
264    ShowMessage(Box<rmut_core::message::MessageView>),
265    /// The draft is back in the front end's hands: put it on screen
266    /// again, however drafts are shown.
267    ShowDraft,
268    /// A mailto: to compose to, the way one on the command line is
269    /// (mutt's list-action landed on a mailto: header).
270    Mailto(rmut_core::mailto::Mailto),
271    /// Hand this file to the editor, then come back to the compose
272    /// menu (mutt's new-mime made it).
273    EditFile(std::path::PathBuf),
274    /// Run a shell command with the display stood down, mutt's `!`.
275    Shell(String),
276    /// Stop, and pick the display back up when the job resumes.
277    Suspend,
278    /// Open an editor on this draft, and put it back on screen
279    /// afterwards. Only the front end knows how to stand its display
280    /// down for one.
281    Editor(crate::Compose),
282}
283
284impl Session {
285    /// mutt's limit prompt.
286    pub fn ask_limit(&self) -> Ask {
287        Ask::Line {
288            label: "Limit (~f/~s/~b/~t/~c/~d/flags, ! | (), empty=all): ".into(),
289            prefill: self
290                .limit
291                .as_ref()
292                .map(|(raw, _)| raw.clone())
293                .unwrap_or_default(),
294            wants: Wants::Pattern,
295            what: AskKind::Limit,
296        }
297    }
298
299    pub fn ask_search(&self, back: bool) -> Ask {
300        Ask::Line {
301            label: match back {
302                true => "Reverse search: ".into(),
303                false => "Search: ".into(),
304            },
305            prefill: String::new(),
306            wants: Wants::Pattern,
307            what: AskKind::Search { back },
308        }
309    }
310
311    /// A mark applied to everything matching a pattern. None when it
312    /// would have to write a read-only mailbox.
313    pub fn ask_pattern(&mut self, op: PatternOp) -> Option<Ask> {
314        if matches!(op, PatternOp::Delete | PatternOp::Undelete) && self.deny_readonly() {
315            return None;
316        }
317        Some(Ask::Line {
318            label: format!("{} messages matching: ", op.label()),
319            prefill: String::new(),
320            wants: Wants::Pattern,
321            what: AskKind::Pattern { op },
322        })
323    }
324
325    /// mutt's mark-message: a hotkey that jumps back to the message
326    /// under the cursor. mutt prefixes it with $mark_macro_prefix
327    /// (`'`); rmut's macros are one key, so the stroke is the key.
328    pub fn ask_mark_message(&mut self) -> Option<Ask> {
329        let Some(msg_id) = self
330            .visible
331            .get(self.sel)
332            .and_then(|&i| self.msgs[i].env.msg_id.clone())
333        else {
334            self.error("No message ID to macro.");
335            return None;
336        };
337        Some(Ask::Line {
338            label: "Enter macro stroke: ".into(),
339            prefill: String::new(),
340            wants: Wants::Other,
341            what: AskKind::MarkMessage { msg_id },
342        })
343    }
344
345    /// mutt's list-action: the RFC 2369 actions the message under the
346    /// cursor offers, as a one-key menu.
347    pub fn ask_list_action(&mut self) -> Option<Ask> {
348        let &i = self.visible.get(self.sel)?;
349        let raw = self.message_bytes(i)?;
350        let actions = rmut_core::message::list_actions(&raw);
351        if actions.iter().all(|(_, url)| url.is_none()) {
352            self.error("No list actions available for this message.");
353            return None;
354        }
355        let label = actions
356            .iter()
357            .map(|(name, url)| {
358                let (head, tail) = name.split_at(1);
359                match url {
360                    Some(_) => format!("({}){tail}", head.to_lowercase()),
361                    None => format!("-{}{tail}-", head.to_lowercase()),
362                }
363            })
364            .collect::<Vec<_>>()
365            .join(" ");
366        Some(Ask::Key {
367            label: format!("List action: {label}: "),
368            what: AskKind::ListAction { actions },
369        })
370    }
371
372    /// Where to save or copy. None when there is nothing to copy, or
373    /// when saving would have to write a read-only mailbox.
374    pub fn ask_copy(&mut self, delete: bool, tagged: bool) -> Option<Ask> {
375        self.ask_copy_decode(delete, tagged, false)
376    }
377
378    /// mutt's decode-save / decode-copy, which are save / copy of the
379    /// decoded message.
380    pub fn ask_copy_decode(&mut self, delete: bool, tagged: bool, decode: bool) -> Option<Ask> {
381        self.visible.get(self.sel)?;
382        // Save marks the original deleted; a plain copy is fine.
383        if delete && self.deny_readonly() {
384            return None;
385        }
386        Some(Ask::Line {
387            label: match (delete, decode) {
388                (true, false) => "Save to mailbox: ".into(),
389                (false, false) => "Copy to mailbox: ".into(),
390                (true, true) => "Decode-save to mailbox: ".into(),
391                (false, true) => "Decode-copy to mailbox: ".into(),
392            },
393            prefill: self
394                .save_name_target()
395                .or_else(|| self.config.mail.save.clone())
396                .unwrap_or_default(),
397            wants: Wants::Mailbox,
398            what: AskKind::CopyTo {
399                delete,
400                tagged,
401                decode,
402            },
403        })
404    }
405
406    /// mutt's $save_name and $force_name: the mailbox named after
407    /// the sender's local part, under $folder. $save_name offers it
408    /// when it is already there, $force_name whether or not.
409    fn save_name_target(&self) -> Option<String> {
410        if !self.config.mail.save_name && !self.config.mail.force_name {
411            return None;
412        }
413        let &i = self.visible.get(self.sel)?;
414        let from = rmut_core::message::first_header(&self.msgs[i].env.file.path, "From")?;
415        let address = rmut_core::compose::bare_address(&from)?;
416        let local = address.split('@').next()?.to_lowercase();
417        if local.is_empty() {
418            return None;
419        }
420        let spec = format!("={local}");
421        let expanded = self.expand_folder(&spec);
422        match self.config.mail.force_name || mailbox_exists(&expanded) {
423            true => Some(spec),
424            false => None,
425        }
426    }
427
428    pub fn ask_pipe(&self, tagged: bool) -> Option<Ask> {
429        self.visible.get(self.sel)?;
430        Some(Ask::Line {
431            label: "Pipe to command: ".into(),
432            prefill: String::new(),
433            wants: Wants::Command,
434            what: AskKind::Pipe { tagged },
435        })
436    }
437
438    pub fn ask_bounce(&self, tagged: bool) -> Option<Ask> {
439        self.visible.get(self.sel)?;
440        Some(Ask::Line {
441            label: "Bounce message to: ".into(),
442            prefill: String::new(),
443            wants: Wants::Address,
444            what: AskKind::BounceTo { tagged },
445        })
446    }
447
448    /// mutt's $print: the question before `p` does anything. rmut has
449    /// always asked with Enter declining, which is mutt's ask-no
450    /// default; the other three answer it for you.
451    pub fn ask_print(&mut self, tagged: bool) -> Option<Ask> {
452        self.visible.get(self.sel)?;
453        let quad = self
454            .config
455            .mail
456            .print_confirm
457            .clone()
458            .unwrap_or_else(|| "ask-no".into());
459        match quad.as_str() {
460            "no" => {
461                self.error("printing is off ([mail] print_confirm)");
462                return None;
463            }
464            "yes" => {
465                self.print_current(tagged);
466                return None;
467            }
468            _ => {}
469        }
470        let n = self.op_targets(tagged).len();
471        Some(Ask::Key {
472            label: match n {
473                1 => "Print message? (y/n): ".to_string(),
474                _ => format!("Print {n} messages? (y/n): "),
475            },
476            what: AskKind::PrintConfirm {
477                tagged,
478                default_yes: quad == "ask-yes",
479            },
480        })
481    }
482
483    /// mutt's create-alias, on the sender of the selected message.
484    pub fn ask_alias(&mut self) -> Option<Ask> {
485        let &i = self.visible.get(self.sel)?;
486        let path = self.msgs[i].env.file.path.clone();
487        let Some(from) = rmut_core::message::first_header(&path, "From") else {
488            self.error("the message has no From header");
489            return None;
490        };
491        let nick = rmut_core::compose::bare_address(&from)
492            .and_then(|a| a.split('@').next().map(|l| l.to_lowercase()))
493            .unwrap_or_default();
494        Some(Ask::Line {
495            label: "Alias as (nick): ".into(),
496            prefill: nick,
497            wants: Wants::Other,
498            what: AskKind::AliasNick {
499                addr: from.trim().to_string(),
500            },
501        })
502    }
503
504    /// mutt's edit-label: prefilled with the current label when one
505    /// message is under the cursor.
506    pub fn ask_edit_label(&self, tagged: bool) -> Option<Ask> {
507        let targets = self.op_targets(tagged);
508        if targets.is_empty() {
509            return None;
510        }
511        let prefill = if !tagged && targets.len() == 1 {
512            self.msgs[targets[0]].env.label.clone().unwrap_or_default()
513        } else {
514            String::new()
515        };
516        Some(Ask::Line {
517            label: "Label: ".into(),
518            prefill,
519            wants: Wants::Other,
520            what: AskKind::EditLabel { tagged },
521        })
522    }
523
524    pub fn ask_sort(&self) -> Ask {
525        Ask::Key {
526            label: "Sort: (d)ate (f)rom (s)ubject si(z)e (t)hreads (y) label, uppercase reverses: "
527                .into(),
528            what: AskKind::Sort,
529        }
530    }
531
532    /// Leaving for good: mutt's $quit decides whether to ask, and
533    /// what follows is the purge question, or nothing at all.
534    pub fn leave(&mut self) -> Option<Ask> {
535        match self.config.mail.quit.as_deref().unwrap_or("yes") {
536            "no" => {
537                self.error("quitting is off ($quit = no)");
538                None
539            }
540            "yes" => self.leave_now(),
541            quit => {
542                // ask-no asks the same question; what differs is
543                // what Enter takes.
544                self.quit_default = quit != "ask-no";
545                Some(Ask::Key {
546                    label: "Quit rmut? (y/n): ".into(),
547                    what: AskKind::QuitConfirm,
548                })
549            }
550        }
551    }
552
553    /// The leaving itself: mark what was left unread as old, and let
554    /// the purge question have the last word.
555    fn leave_now(&mut self) -> Option<Ask> {
556        self.mark_old_unread();
557        // Like mutt: flag changes are written silently; only pending
558        // deletions raise a question.
559        if self.deleted_count() > 0 {
560            return self.ask_purge(true);
561        }
562        if self.pending_count() > 0 {
563            self.sync(true);
564        }
565        self.requests.push(Request::Quit);
566        None
567    }
568
569    /// Purge the deleted messages before leaving this mailbox?
570    ///
571    /// mutt's $delete decides it without asking when it is set to yes
572    /// or no; then there is nothing to ask and the work is already
573    /// done, so this returns None.
574    pub fn ask_purge(&mut self, quit: bool) -> Option<Ask> {
575        match self.config.mail.delete.as_deref() {
576            Some("yes") | Some("no") => {
577                let purge = self.config.mail.delete.as_deref() == Some("yes");
578                self.sync(purge);
579                if quit {
580                    self.requests.push(Request::Quit);
581                }
582                None
583            }
584            _ => Some(Ask::Key {
585                label: format!("Purge {} deleted message(s)? (y/n): ", self.deleted_count()),
586                what: AskKind::Purge { quit },
587            }),
588        }
589    }
590
591    /// A header of the draft in hand (To, Cc, Bcc, Subject).
592    pub fn ask_header(&self, name: &str) -> Option<Ask> {
593        self.draft()?;
594        Some(Ask::Line {
595            label: format!("{name}: "),
596            prefill: self.draft_header(name),
597            wants: match name {
598                "Subject" => Wants::Other,
599                _ => Wants::Address,
600            },
601            what: AskKind::EditHeader {
602                name: name.to_string(),
603            },
604        })
605    }
606
607    /// Where the sent copy goes, prefilled with where it would go.
608    pub fn ask_fcc(&self) -> Option<Ask> {
609        let draft = self.draft()?;
610        Some(Ask::Line {
611            label: "Fcc: ".into(),
612            prefill: match draft.fcc.clone() {
613                Some(fcc) => fcc,
614                None => self.default_fcc(Some(draft)),
615            },
616            wants: Wants::Mailbox,
617            what: AskKind::EditFcc,
618        })
619    }
620
621    pub fn ask_attach_file(&self) -> Option<Ask> {
622        self.draft()?;
623        Some(Ask::Line {
624            label: "Attach file: ".into(),
625            prefill: String::new(),
626            wants: Wants::Other,
627            what: AskKind::AttachFile,
628        })
629    }
630
631    /// The description (or content-type) of the attachment the menu's
632    /// `sel`-th row stands for.
633    pub fn ask_attach_field(&mut self, sel: usize, is_type: bool) -> Option<Ask> {
634        let draft = self.draft()?;
635        let fixed = 1 + usize::from(draft.attach.is_some());
636        if sel < fixed {
637            self.error("only Attach: files can be edited");
638            return None;
639        }
640        let k = sel - fixed;
641        let attachment = crate::draft_full(draft)
642            .ok()
643            .map(|full| rmut_core::compose::extract_attachments(&full).1)
644            .and_then(|mut atts| (k < atts.len()).then(|| atts.swap_remove(k)))?;
645        let prefill = match is_type {
646            true => attachment
647                .mime
648                .clone()
649                .unwrap_or_else(|| rmut_core::compose::content_type(&attachment.path).to_string()),
650            false => attachment.description.clone().unwrap_or_default(),
651        };
652        Some(Ask::Line {
653            label: match is_type {
654                true => "Content-Type: ".into(),
655                false => "Description: ".into(),
656            },
657            prefill,
658            wants: Wants::Other,
659            what: AskKind::AttachField { k, is_type },
660        })
661    }
662
663    /// mutt's rename-attachment (Ctrl+O): "Send attachment with name: ",
664    /// the current name prefilled; empty takes the override off.
665    pub fn ask_rename_attachment(&mut self, sel: usize) -> Option<Ask> {
666        let k = self.attach_index(sel)?;
667        let name = self
668            .attachments()
669            .get(k)
670            .map(|a| a.send_name().to_string())
671            .unwrap_or_default();
672        Some(Ask::Line {
673            label: "Send attachment with name: ".into(),
674            prefill: name,
675            wants: Wants::Other,
676            what: AskKind::RenameAttachment { k },
677        })
678    }
679
680    /// mutt's new-mime (n): a file to make and attach, then its type.
681    pub fn ask_new_mime(&self) -> Option<Ask> {
682        self.draft()?;
683        Some(Ask::Line {
684            label: "New file: ".into(),
685            prefill: String::new(),
686            wants: Wants::Other,
687            what: AskKind::NewMimeFile,
688        })
689    }
690
691    /// mutt's write-fcc (w): the message as it stands, into a mailbox,
692    /// without sending it. The open mailbox is the offer.
693    pub fn ask_write_fcc(&self) -> Option<Ask> {
694        self.draft()?;
695        Some(Ask::Line {
696            label: "Write message to mailbox: ".into(),
697            prefill: self.title.clone(),
698            wants: Wants::Mailbox,
699            what: AskKind::WriteFcc,
700        })
701    }
702
703    pub fn ask_security(&self) -> Option<Ask> {
704        self.draft()?;
705        Some(Ask::Key {
706            label: "Security: (e)ncrypt (s)ign (b)oth (c)lear: ".into(),
707            what: AskKind::Security,
708        })
709    }
710
711    pub fn ask_postpone(&mut self) -> Option<Ask> {
712        self.draft()?;
713        // mutt's $postpone: yes/no decide without asking, ask-yes /
714        // ask-no ask with the matching default.
715        match self
716            .config
717            .mail
718            .postpone
719            .as_deref()
720            .unwrap_or("ask-yes")
721            .trim()
722            .to_lowercase()
723            .as_str()
724        {
725            "yes" => {
726                if let Some(draft) = self.take_draft() {
727                    self.postpone_draft(draft);
728                }
729                None
730            }
731            "no" => {
732                if let Some(draft) = self.take_draft() {
733                    let _ = std::fs::remove_file(&draft.path);
734                    self.note("message discarded");
735                }
736                None
737            }
738            other => Some(Ask::Key {
739                label: "Postpone this message? (y/n): ".into(),
740                what: AskKind::PostponeAsk {
741                    default_yes: other != "ask-no",
742                },
743            }),
744        }
745    }
746
747    /// mutt's `!`: run something with the display out of the way.
748    pub fn ask_shell(&self) -> Ask {
749        Ask::Line {
750            label: "Shell command: ".into(),
751            prefill: String::new(),
752            wants: Wants::Command,
753            what: AskKind::Shell,
754        }
755    }
756
757    /// Ctrl+Z: ask to be put in the background. A front end that
758    /// cannot be suspended simply does not honour it.
759    pub fn request_suspend(&mut self) {
760        self.requests.push(Request::Suspend);
761    }
762
763    /// Hand back an answer. The next question, when there is one.
764    pub fn answer(&mut self, what: AskKind, answer: Answer<'_>) -> Option<Ask> {
765        match (what, answer) {
766            (AskKind::Limit, Answer::Line(input)) => {
767                self.set_limit(input);
768                None
769            }
770            (AskKind::Search { back }, Answer::Line(input)) => {
771                self.search_rev = back;
772                if !input.is_empty() {
773                    match self.compile_search(input) {
774                        Ok(patterns) => {
775                            self.resolve_body_terms(&patterns);
776                            self.last_search = Some(patterns);
777                        }
778                        Err(err) => {
779                            self.error(format!("bad pattern: {err}"));
780                            return None;
781                        }
782                    }
783                }
784                self.search_next();
785                None
786            }
787            (AskKind::Pattern { op }, Answer::Line(input)) => {
788                let flag_safe = self.config.mail.flag_safe;
789                self.apply_pattern(input, op.verb(), |m| op.apply(m, flag_safe));
790                None
791            }
792            (
793                AskKind::CopyTo {
794                    delete,
795                    tagged,
796                    decode,
797                },
798                Answer::Line(input),
799            ) => {
800                let input = self.expand_folder(input);
801                // mutt's $confirmappend: adding to a mailbox that is
802                // already there is worth a question.
803                if self.config.mail.confirmappend && mailbox_exists(&input) {
804                    return Some(Ask::Key {
805                        label: format!("Append messages to {input}? (y/n): "),
806                        what: AskKind::AppendConfirm {
807                            input,
808                            delete,
809                            tagged,
810                            decode,
811                        },
812                    });
813                }
814                self.copy_message(&input, delete, tagged, decode);
815                None
816            }
817            (
818                AskKind::AppendConfirm {
819                    input,
820                    delete,
821                    tagged,
822                    decode,
823                },
824                Answer::Key(key),
825            ) => {
826                // ask-yes, like mutt's: Enter takes the yes.
827                if matches!(key, Key::Char('y') | Key::Enter) {
828                    self.copy_message(&input, delete, tagged, decode);
829                }
830                None
831            }
832            (AskKind::QuitConfirm, Answer::Key(key)) => {
833                let yes = match key {
834                    Key::Char('y') => true,
835                    Key::Char('n') => false,
836                    Key::Enter => self.quit_default,
837                    _ => false,
838                };
839                match yes {
840                    true => self.leave_now(),
841                    false => None,
842                }
843            }
844            (AskKind::Pipe { tagged }, Answer::Line(command)) => {
845                self.pipe_message(command, tagged);
846                None
847            }
848            (AskKind::AliasNick { addr }, Answer::Line(nick)) => {
849                self.create_alias(nick, &addr);
850                None
851            }
852            (AskKind::EditLabel { tagged }, Answer::Line(input)) => {
853                self.edit_label(input, tagged);
854                None
855            }
856            // ---- the compose flow: one question leads to the next
857            (AskKind::ReplyTo, Answer::Key(key)) => match key {
858                Key::Char('y') | Key::Enter => self.answer_reply_to(true),
859                Key::Char('n') => self.answer_reply_to(false),
860                _ => {
861                    self.cancel_setup();
862                    self.note("reply cancelled");
863                    None
864                }
865            },
866            (AskKind::EditHeader { name }, Answer::Line(input)) => {
867                let value = match name.as_str() {
868                    "Subject" => input.to_string(),
869                    _ => rmut_core::alias::expand(
870                        input,
871                        &rmut_core::alias::load(self.config.mail.alias_file.as_deref()),
872                    ),
873                };
874                self.set_draft_header(&name, &value);
875                None
876            }
877            (AskKind::Shell, Answer::Line(command)) => {
878                // Empty is mutt's bare `!`: an interactive $shell.
879                {
880                    self.requests.push(Request::Shell(command.to_string()));
881                }
882                None
883            }
884            (AskKind::EditFcc, Answer::Line(input)) => {
885                if let Some(draft) = self.draft_mut() {
886                    draft.fcc = Some(input.trim().to_string());
887                }
888                None
889            }
890            (AskKind::AttachFile, Answer::Line(input)) => {
891                self.attach_file(input);
892                None
893            }
894            (AskKind::AttachField { k, is_type }, Answer::Line(input)) => {
895                self.set_attach_field(k, input, is_type);
896                None
897            }
898            (AskKind::RenameAttachment { k }, Answer::Line(input)) => {
899                let name = input.trim().to_string();
900                self.edit_attachment(k, |a| {
901                    // The file's own name is no override.
902                    let own = a.path.file_name().and_then(|n| n.to_str()).unwrap_or("");
903                    a.name = (!name.is_empty() && name != own).then(|| name.clone());
904                });
905                self.requests.push(Request::ShowDraft);
906                None
907            }
908            (AskKind::NewMimeFile, Answer::Line(input)) => {
909                let path = input.trim().to_string();
910                if path.is_empty() {
911                    self.requests.push(Request::ShowDraft);
912                    return None;
913                }
914                Some(Ask::Line {
915                    label: "Content-Type: ".into(),
916                    prefill: String::new(),
917                    wants: Wants::Other,
918                    what: AskKind::NewMimeType { path },
919                })
920            }
921            (AskKind::NewMimeType { path }, Answer::Line(input)) => {
922                self.new_mime(&path, input.trim());
923                None
924            }
925            (AskKind::WriteFcc, Answer::Line(input)) => {
926                let mailbox = input.trim().to_string();
927                if !mailbox.is_empty() {
928                    self.write_draft_to(&mailbox);
929                }
930                self.requests.push(Request::ShowDraft);
931                None
932            }
933            (AskKind::Security, Answer::Key(key)) => {
934                if let Some(draft) = self.draft_mut() {
935                    draft.security = match key {
936                        Key::Char('e') => Security::Encrypt,
937                        Key::Char('s') => Security::Sign,
938                        Key::Char('b') => Security::Both,
939                        Key::Char('c') => Security::None,
940                        _ => draft.security,
941                    };
942                }
943                None
944            }
945            (AskKind::PostponeAsk { default_yes }, Answer::Key(key)) => {
946                let postpone = match key {
947                    Key::Char('y') => true,
948                    Key::Char('n') => false,
949                    Key::Enter => default_yes,
950                    // Anything else goes back to the menu.
951                    _ => {
952                        self.requests.push(Request::ShowDraft);
953                        return None;
954                    }
955                };
956                if let Some(draft) = self.take_draft() {
957                    if postpone {
958                        self.postpone_draft(draft);
959                    } else {
960                        let _ = std::fs::remove_file(&draft.path);
961                        self.note("message discarded");
962                    }
963                }
964                None
965            }
966            (AskKind::NoAttach, Answer::Key(key)) => match key {
967                // ask-no: Enter goes back to the menu, where `a`
968                // attaches the file that was forgotten.
969                Key::Char('y') => {
970                    self.confirm_attachment();
971                    self.send_draft()
972                }
973                _ => {
974                    self.note("not sent; a attaches a file");
975                    self.requests.push(Request::ShowDraft);
976                    None
977                }
978            },
979            (AskKind::ComposeTo, Answer::Line(input)) => self.answer_to(input),
980            (AskKind::ComposeCc, Answer::Line(input)) => self.answer_cc(input),
981            (AskKind::ComposeBcc, Answer::Line(input)) => self.answer_bcc(input),
982            (AskKind::ComposeSubject, Answer::Line(input)) => self.answer_subject(input),
983            (AskKind::NoSubject { default_yes }, Answer::Key(key)) => match key {
984                Key::Char('n') => self.answer_subject_kept(),
985                Key::Char('y') => {
986                    self.cancel_setup();
987                    self.error("aborted (no subject)");
988                    None
989                }
990                // ask-yes: Enter aborts, like mutt; ask-no keeps it.
991                Key::Enter if !default_yes => self.answer_subject_kept(),
992                _ => {
993                    self.cancel_setup();
994                    self.error("aborted (no subject)");
995                    None
996                }
997            },
998            (AskKind::IncludeReply { default_yes }, Answer::Key(key)) => match key {
999                Key::Char('n') => self.answer_include(false),
1000                Key::Char('y') => self.answer_include(true),
1001                Key::Enter => self.answer_include(default_yes),
1002                _ => {
1003                    self.cancel_setup();
1004                    self.note("reply cancelled");
1005                    None
1006                }
1007            },
1008            (AskKind::ForwardAttach, Answer::Key(key)) => match key {
1009                // ask-yes: Enter takes the attachment.
1010                Key::Char('n') => self.answer_forward_attach(false),
1011                Key::Char('y') | Key::Enter => self.answer_forward_attach(true),
1012                _ => {
1013                    self.cancel_setup();
1014                    self.note("forward cancelled");
1015                    None
1016                }
1017            },
1018            (AskKind::BounceTo { tagged }, Answer::Line(input)) => {
1019                let to = rmut_core::alias::expand(
1020                    input,
1021                    &rmut_core::alias::load(self.config.mail.alias_file.as_deref()),
1022                );
1023                if to.trim().is_empty() {
1024                    self.note("no recipients, bounce cancelled");
1025                    return None;
1026                }
1027                let n = self.op_targets(tagged).len();
1028                Some(Ask::Key {
1029                    label: match n {
1030                        1 => format!("Bounce message to {to}? (y/n): "),
1031                        _ => format!("Bounce {n} messages to {to}? (y/n): "),
1032                    },
1033                    what: AskKind::BounceConfirm { to, tagged },
1034                })
1035            }
1036            (AskKind::BounceConfirm { to, tagged }, Answer::Key(key)) => {
1037                if key == Key::Char('y') {
1038                    self.bounce_current(&to, tagged);
1039                }
1040                None
1041            }
1042            (
1043                AskKind::PrintConfirm {
1044                    tagged,
1045                    default_yes,
1046                },
1047                Answer::Key(key),
1048            ) => {
1049                if key == Key::Char('y') || (default_yes && key == Key::Enter) {
1050                    self.print_current(tagged);
1051                }
1052                None
1053            }
1054            (AskKind::Purge { quit }, Answer::Key(key)) => {
1055                // ask-yes, like mutt's $delete: Enter takes the yes.
1056                // n writes flag changes but keeps the messages marked
1057                // deleted; anything else calls the whole thing off,
1058                // including the quit that asked.
1059                if matches!(key, Key::Char('y') | Key::Char('n') | Key::Enter) {
1060                    self.sync(key != Key::Char('n'));
1061                    if quit {
1062                        self.requests.push(Request::Quit);
1063                    }
1064                }
1065                None
1066            }
1067            (AskKind::MarkMessage { msg_id }, Answer::Line(input)) => {
1068                let stroke = input.trim();
1069                if stroke.is_empty() {
1070                    return None;
1071                }
1072                // The id without its brackets (a `<` would read as a
1073                // key name in the sequence), regex-quoted for ~i.
1074                let id: String = msg_id
1075                    .trim_matches(|c| c == '<' || c == '>')
1076                    .chars()
1077                    .flat_map(|c| match c {
1078                        '.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '^'
1079                        | '$' | '\\' => vec!['\\', c],
1080                        c => vec![c],
1081                    })
1082                    .collect();
1083                self.requests
1084                    .push(Request::Command(rmut_core::command::Command::Macro {
1085                        menu: rmut_core::command::Menu::Index,
1086                        key: stroke.to_string(),
1087                        seq: format!("/~i {id}<enter>"),
1088                    }));
1089                self.note(format!("Message bound to {stroke}."));
1090                None
1091            }
1092            (AskKind::ListAction { actions }, Answer::Key(key)) => {
1093                let Key::Char(c) = key else {
1094                    return None;
1095                };
1096                let (name, url) = actions
1097                    .iter()
1098                    .find(|(name, _)| name.starts_with(c.to_ascii_uppercase()))?;
1099                match url {
1100                    None => self.error(format!("No list action available for {name}.")),
1101                    Some(url) if !url.to_ascii_lowercase().starts_with("mailto:") => {
1102                        self.error("List actions only support mailto: URIs. (Try a browser?)");
1103                    }
1104                    Some(url) => match rmut_core::mailto::parse(url) {
1105                        Some(mailto) => self.requests.push(Request::Mailto(mailto)),
1106                        None => self.error("Could not parse mailto: URI."),
1107                    },
1108                }
1109                None
1110            }
1111            (AskKind::Sort, Answer::Key(key)) => {
1112                let (sort, rev) = match key {
1113                    Key::Char('d') => (SortKey::Date, false),
1114                    Key::Char('D') => (SortKey::Date, true),
1115                    Key::Char('f') => (SortKey::From, false),
1116                    Key::Char('F') => (SortKey::From, true),
1117                    Key::Char('s') => (SortKey::Subject, false),
1118                    Key::Char('S') => (SortKey::Subject, true),
1119                    Key::Char('z') => (SortKey::Size, false),
1120                    Key::Char('Z') => (SortKey::Size, true),
1121                    Key::Char('t') | Key::Char('T') => (SortKey::Threads, false),
1122                    Key::Char('o') => (SortKey::To, false),
1123                    Key::Char('O') => (SortKey::To, true),
1124                    Key::Char('y') => (SortKey::Label, false),
1125                    Key::Char('Y') => (SortKey::Label, true),
1126                    Key::Char('u') => (SortKey::Unsorted, false),
1127                    Key::Char('U') => (SortKey::Unsorted, true),
1128                    _ => return None,
1129                };
1130                self.sort = sort;
1131                self.sort_rev = rev;
1132                self.apply_sort();
1133                self.note(format!(
1134                    "sorted by {}{}",
1135                    sort.name(),
1136                    if rev { " (reverse)" } else { "" }
1137                ));
1138                None
1139            }
1140            // A key answer to a line question, or the other way round:
1141            // nothing to do with it.
1142            _ => None,
1143        }
1144    }
1145
1146    /// Anything the session wants the front end to do, oldest first.
1147    pub fn take_request(&mut self) -> Option<Request> {
1148        match self.requests.is_empty() {
1149            true => None,
1150            false => Some(self.requests.remove(0)),
1151        }
1152    }
1153
1154    /// mutt's `+x` / `=x`: a mailbox under $folder.
1155    fn expand_folder(&self, input: &str) -> String {
1156        rmut_core::config::expand_folder(input, self.config.mail.folder.as_deref())
1157    }
1158
1159    /// The limit pattern, or none of it when the answer is empty or
1160    /// mutt's "all".
1161    fn set_limit(&mut self, input: &str) {
1162        let keep = self.selected_path();
1163        if input.is_empty() || input == "all" {
1164            self.limit = None;
1165        } else {
1166            match self.compile_search(input) {
1167                Ok(patterns) => {
1168                    self.resolve_body_terms(&patterns);
1169                    self.limit = Some((input.to_string(), patterns));
1170                }
1171                Err(err) => {
1172                    self.error(format!("bad pattern: {err}"));
1173                    return;
1174                }
1175            }
1176        }
1177        self.rebuild_visible(keep);
1178        if self.visible.is_empty() {
1179            self.note("no messages match the limit");
1180        }
1181    }
1182}