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