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    /// An index search; `back` is mutt's search-reverse.
69    Search {
70        back: bool,
71    },
72    /// A mark applied to every message matching a pattern.
73    Pattern {
74        op: PatternOp,
75    },
76    /// Where to copy the messages, and whether the originals are
77    /// marked deleted afterwards (mutt's save).
78    CopyTo {
79        delete: bool,
80        tagged: bool,
81        /// mutt's decode-save / decode-copy: deliver the decoded
82        /// message rather than the raw bytes.
83        decode: bool,
84    },
85    Pipe {
86        tagged: bool,
87    },
88    /// mutt's $print: print this message? `default_yes` is what Enter
89    /// takes, from ask-yes or ask-no (rmut's default, as in mutt).
90    PrintConfirm {
91        tagged: bool,
92        default_yes: bool,
93    },
94    BounceTo {
95        tagged: bool,
96    },
97    BounceConfirm {
98        to: String,
99        tagged: bool,
100    },
101    /// Purge the deleted messages? `quit` leaves afterwards.
102    Purge {
103        quit: bool,
104    },
105    /// mutt's sort menu: one key picks the order.
106    Sort,
107    /// The nick for a create-alias; the address came from the message.
108    AliasNick {
109        addr: String,
110    },
111    /// mutt's edit-label: the X-Label for the message or the tagged
112    /// set. Empty clears it.
113    EditLabel {
114        tagged: bool,
115    },
116    /// mutt's $reply_to (ask-yes): reply to the Reply-To address?
117    ReplyTo,
118    /// Who the draft goes to.
119    ComposeTo,
120    /// mutt's $askcc / $askbcc: who else gets a copy.
121    ComposeCc,
122    ComposeBcc,
123    /// What it is about.
124    ComposeSubject,
125    /// mutt's $abort_nosubject: no subject, abort? `default_yes` is
126    /// what Enter takes, from ask-yes (mutt's default) or ask-no.
127    NoSubject {
128        default_yes: bool,
129    },
130    /// mutt's $include: quote the original in the reply? `default_yes`
131    /// is what Enter takes, from ask-yes or ask-no.
132    IncludeReply {
133        default_yes: bool,
134    },
135    /// mime_forward = "ask": forward the original as an attachment?
136    ForwardAttach,
137    /// $abort_noattach = ask: the body mentions an attachment and
138    /// none is attached. Send it anyway?
139    NoAttach,
140    /// A header of the draft in hand, edited from the compose menu.
141    EditHeader {
142        name: String,
143    },
144    /// Where the sent copy goes (empty keeps none).
145    EditFcc,
146    /// A file to attach.
147    AttachFile,
148    /// The description or the content-type of the k-th attachment.
149    AttachField {
150        k: usize,
151        is_type: bool,
152    },
153    /// mutt's compose menu `p`: sign, encrypt, both, or neither.
154    Security,
155    /// Leaving the compose menu: postpone the draft, or throw it away?
156    PostponeAsk {
157        default_yes: bool,
158    },
159    /// mutt's `!`: a shell command to run with the display stood down.
160    Shell,
161    /// mutt's $quit: leave the mailbox and the program?
162    QuitConfirm,
163    /// mutt's $confirmappend: the target mailbox exists; add to it?
164    AppendConfirm {
165        input: String,
166        delete: bool,
167        tagged: bool,
168        decode: bool,
169    },
170}
171
172/// The pattern operations, mutt's D/U/T/Ctrl+T.
173#[derive(Clone, Copy, PartialEq, Eq)]
174pub enum PatternOp {
175    Delete,
176    Undelete,
177    Tag,
178    Untag,
179}
180
181impl PatternOp {
182    /// The word the undo step and the note use.
183    fn verb(self) -> &'static str {
184        match self {
185            PatternOp::Delete => "deleted",
186            PatternOp::Undelete => "undeleted",
187            PatternOp::Tag => "tagged",
188            PatternOp::Untag => "untagged",
189        }
190    }
191
192    /// The word the question uses.
193    fn label(self) -> &'static str {
194        match self {
195            PatternOp::Delete => "Delete",
196            PatternOp::Undelete => "Undelete",
197            PatternOp::Tag => "Tag",
198            PatternOp::Untag => "Untag",
199        }
200    }
201
202    fn apply(self, m: &mut Msg) {
203        match self {
204            PatternOp::Delete => {
205                if !m.env.file.flags.deleted {
206                    m.env.file.flags.deleted = true;
207                    m.dirty = true;
208                }
209            }
210            PatternOp::Undelete => {
211                if m.env.file.flags.deleted {
212                    m.env.file.flags.deleted = false;
213                    m.dirty = true;
214                }
215            }
216            PatternOp::Tag => m.env.tagged = true,
217            PatternOp::Untag => m.env.tagged = false,
218        }
219    }
220}
221
222/// Something only the front end can do, handed back for it to honour
223/// or refuse.
224pub enum Request {
225    /// Nothing is pending; the session is done being driven.
226    Quit,
227    /// The config moved: whatever the front end derives from it (the
228    /// colours, the key tables) wants rebuilding.
229    ConfigChanged,
230    /// A command line the session does not handle, because it binds a
231    /// key, queues one, or runs a function.
232    Command(rmut_core::command::Command),
233    /// The mailbox counts moved: a front end showing them (a
234    /// sidebar) wants to redraw.
235    MailboxesChanged,
236    /// A message is ready to read: show it however messages are
237    /// shown (mutt puts it in the pager).
238    ShowMessage(Box<rmut_core::message::MessageView>),
239    /// The draft is back in the front end's hands: put it on screen
240    /// again, however drafts are shown.
241    ShowDraft,
242    /// Run a shell command with the display stood down, mutt's `!`.
243    Shell(String),
244    /// Stop, and pick the display back up when the job resumes.
245    Suspend,
246    /// Open an editor on this draft, and put it back on screen
247    /// afterwards. Only the front end knows how to stand its display
248    /// down for one.
249    Editor(crate::Compose),
250}
251
252impl Session {
253    /// mutt's limit prompt.
254    pub fn ask_limit(&self) -> Ask {
255        Ask::Line {
256            label: "Limit (~f/~s/~b/~t/~c/~d/flags, ! | (), empty=all): ".into(),
257            prefill: self
258                .limit
259                .as_ref()
260                .map(|(raw, _)| raw.clone())
261                .unwrap_or_default(),
262            wants: Wants::Pattern,
263            what: AskKind::Limit,
264        }
265    }
266
267    pub fn ask_search(&self, back: bool) -> Ask {
268        Ask::Line {
269            label: match back {
270                true => "Reverse search: ".into(),
271                false => "Search: ".into(),
272            },
273            prefill: String::new(),
274            wants: Wants::Pattern,
275            what: AskKind::Search { back },
276        }
277    }
278
279    /// A mark applied to everything matching a pattern. None when it
280    /// would have to write a read-only mailbox.
281    pub fn ask_pattern(&mut self, op: PatternOp) -> Option<Ask> {
282        if matches!(op, PatternOp::Delete | PatternOp::Undelete) && self.deny_readonly() {
283            return None;
284        }
285        Some(Ask::Line {
286            label: format!("{} messages matching: ", op.label()),
287            prefill: String::new(),
288            wants: Wants::Pattern,
289            what: AskKind::Pattern { op },
290        })
291    }
292
293    /// Where to save or copy. None when there is nothing to copy, or
294    /// when saving would have to write a read-only mailbox.
295    pub fn ask_copy(&mut self, delete: bool, tagged: bool) -> Option<Ask> {
296        self.ask_copy_decode(delete, tagged, false)
297    }
298
299    /// mutt's decode-save / decode-copy, which are save / copy of the
300    /// decoded message.
301    pub fn ask_copy_decode(&mut self, delete: bool, tagged: bool, decode: bool) -> Option<Ask> {
302        self.visible.get(self.sel)?;
303        // Save marks the original deleted; a plain copy is fine.
304        if delete && self.deny_readonly() {
305            return None;
306        }
307        Some(Ask::Line {
308            label: match (delete, decode) {
309                (true, false) => "Save to mailbox: ".into(),
310                (false, false) => "Copy to mailbox: ".into(),
311                (true, true) => "Decode-save to mailbox: ".into(),
312                (false, true) => "Decode-copy to mailbox: ".into(),
313            },
314            prefill: self
315                .save_name_target()
316                .or_else(|| self.config.mail.save.clone())
317                .unwrap_or_default(),
318            wants: Wants::Mailbox,
319            what: AskKind::CopyTo {
320                delete,
321                tagged,
322                decode,
323            },
324        })
325    }
326
327    /// mutt's $save_name and $force_name: the mailbox named after
328    /// the sender's local part, under $folder. $save_name offers it
329    /// when it is already there, $force_name whether or not.
330    fn save_name_target(&self) -> Option<String> {
331        if !self.config.mail.save_name && !self.config.mail.force_name {
332            return None;
333        }
334        let &i = self.visible.get(self.sel)?;
335        let from = rmut_core::message::first_header(&self.msgs[i].env.file.path, "From")?;
336        let address = rmut_core::compose::bare_address(&from)?;
337        let local = address.split('@').next()?.to_lowercase();
338        if local.is_empty() {
339            return None;
340        }
341        let spec = format!("={local}");
342        let expanded = self.expand_folder(&spec);
343        match self.config.mail.force_name || mailbox_exists(&expanded) {
344            true => Some(spec),
345            false => None,
346        }
347    }
348
349    pub fn ask_pipe(&self, tagged: bool) -> Option<Ask> {
350        self.visible.get(self.sel)?;
351        Some(Ask::Line {
352            label: "Pipe to command: ".into(),
353            prefill: String::new(),
354            wants: Wants::Command,
355            what: AskKind::Pipe { tagged },
356        })
357    }
358
359    pub fn ask_bounce(&self, tagged: bool) -> Option<Ask> {
360        self.visible.get(self.sel)?;
361        Some(Ask::Line {
362            label: "Bounce message to: ".into(),
363            prefill: String::new(),
364            wants: Wants::Address,
365            what: AskKind::BounceTo { tagged },
366        })
367    }
368
369    /// mutt's $print: the question before `p` does anything. rmut has
370    /// always asked with Enter declining, which is mutt's ask-no
371    /// default; the other three answer it for you.
372    pub fn ask_print(&mut self, tagged: bool) -> Option<Ask> {
373        self.visible.get(self.sel)?;
374        let quad = self
375            .config
376            .mail
377            .print_confirm
378            .clone()
379            .unwrap_or_else(|| "ask-no".into());
380        match quad.as_str() {
381            "no" => {
382                self.error("printing is off ([mail] print_confirm)");
383                return None;
384            }
385            "yes" => {
386                self.print_current(tagged);
387                return None;
388            }
389            _ => {}
390        }
391        let n = self.op_targets(tagged).len();
392        Some(Ask::Key {
393            label: match n {
394                1 => "Print message? (y/n): ".to_string(),
395                _ => format!("Print {n} messages? (y/n): "),
396            },
397            what: AskKind::PrintConfirm {
398                tagged,
399                default_yes: quad == "ask-yes",
400            },
401        })
402    }
403
404    /// mutt's create-alias, on the sender of the selected message.
405    pub fn ask_alias(&mut self) -> Option<Ask> {
406        let &i = self.visible.get(self.sel)?;
407        let path = self.msgs[i].env.file.path.clone();
408        let Some(from) = rmut_core::message::first_header(&path, "From") else {
409            self.error("the message has no From header");
410            return None;
411        };
412        let nick = rmut_core::compose::bare_address(&from)
413            .and_then(|a| a.split('@').next().map(|l| l.to_lowercase()))
414            .unwrap_or_default();
415        Some(Ask::Line {
416            label: "Alias as (nick): ".into(),
417            prefill: nick,
418            wants: Wants::Other,
419            what: AskKind::AliasNick {
420                addr: from.trim().to_string(),
421            },
422        })
423    }
424
425    /// mutt's edit-label: prefilled with the current label when one
426    /// message is under the cursor.
427    pub fn ask_edit_label(&self, tagged: bool) -> Option<Ask> {
428        let targets = self.op_targets(tagged);
429        if targets.is_empty() {
430            return None;
431        }
432        let prefill = if !tagged && targets.len() == 1 {
433            self.msgs[targets[0]].env.label.clone().unwrap_or_default()
434        } else {
435            String::new()
436        };
437        Some(Ask::Line {
438            label: "Label: ".into(),
439            prefill,
440            wants: Wants::Other,
441            what: AskKind::EditLabel { tagged },
442        })
443    }
444
445    pub fn ask_sort(&self) -> Ask {
446        Ask::Key {
447            label: "Sort: (d)ate (f)rom (s)ubject si(z)e (t)hreads (y) label, uppercase reverses: "
448                .into(),
449            what: AskKind::Sort,
450        }
451    }
452
453    /// Leaving for good: mutt's $quit decides whether to ask, and
454    /// what follows is the purge question, or nothing at all.
455    pub fn leave(&mut self) -> Option<Ask> {
456        match self.config.mail.quit.as_deref().unwrap_or("yes") {
457            "no" => {
458                self.error("quitting is off ($quit = no)");
459                None
460            }
461            "yes" => self.leave_now(),
462            quit => {
463                // ask-no asks the same question; what differs is
464                // what Enter takes.
465                self.quit_default = quit != "ask-no";
466                Some(Ask::Key {
467                    label: "Quit rmut? (y/n): ".into(),
468                    what: AskKind::QuitConfirm,
469                })
470            }
471        }
472    }
473
474    /// The leaving itself: mark what was left unread as old, and let
475    /// the purge question have the last word.
476    fn leave_now(&mut self) -> Option<Ask> {
477        self.mark_old_unread();
478        // Like mutt: flag changes are written silently; only pending
479        // deletions raise a question.
480        if self.deleted_count() > 0 {
481            return self.ask_purge(true);
482        }
483        if self.pending_count() > 0 {
484            self.sync(true);
485        }
486        self.requests.push(Request::Quit);
487        None
488    }
489
490    /// Purge the deleted messages before leaving this mailbox?
491    ///
492    /// mutt's $delete decides it without asking when it is set to yes
493    /// or no; then there is nothing to ask and the work is already
494    /// done, so this returns None.
495    pub fn ask_purge(&mut self, quit: bool) -> Option<Ask> {
496        match self.config.mail.delete.as_deref() {
497            Some("yes") | Some("no") => {
498                let purge = self.config.mail.delete.as_deref() == Some("yes");
499                self.sync(purge);
500                if quit {
501                    self.requests.push(Request::Quit);
502                }
503                None
504            }
505            _ => Some(Ask::Key {
506                label: format!("Purge {} deleted message(s)? (y/n): ", self.deleted_count()),
507                what: AskKind::Purge { quit },
508            }),
509        }
510    }
511
512    /// A header of the draft in hand (To, Cc, Bcc, Subject).
513    pub fn ask_header(&self, name: &str) -> Option<Ask> {
514        self.draft()?;
515        Some(Ask::Line {
516            label: format!("{name}: "),
517            prefill: self.draft_header(name),
518            wants: match name {
519                "Subject" => Wants::Other,
520                _ => Wants::Address,
521            },
522            what: AskKind::EditHeader {
523                name: name.to_string(),
524            },
525        })
526    }
527
528    /// Where the sent copy goes, prefilled with where it would go.
529    pub fn ask_fcc(&self) -> Option<Ask> {
530        let draft = self.draft()?;
531        Some(Ask::Line {
532            label: "Fcc: ".into(),
533            prefill: match draft.fcc.clone() {
534                Some(fcc) => fcc,
535                None => self.default_fcc(Some(draft)),
536            },
537            wants: Wants::Mailbox,
538            what: AskKind::EditFcc,
539        })
540    }
541
542    pub fn ask_attach_file(&self) -> Option<Ask> {
543        self.draft()?;
544        Some(Ask::Line {
545            label: "Attach file: ".into(),
546            prefill: String::new(),
547            wants: Wants::Other,
548            what: AskKind::AttachFile,
549        })
550    }
551
552    /// The description (or content-type) of the attachment the menu's
553    /// `sel`-th row stands for.
554    pub fn ask_attach_field(&mut self, sel: usize, is_type: bool) -> Option<Ask> {
555        let draft = self.draft()?;
556        let fixed = 1 + usize::from(draft.attach.is_some());
557        if sel < fixed {
558            self.error("only Attach: files can be edited");
559            return None;
560        }
561        let k = sel - fixed;
562        let attachment = crate::draft_full(draft)
563            .ok()
564            .map(|full| rmut_core::compose::extract_attachments(&full).1)
565            .and_then(|mut atts| (k < atts.len()).then(|| atts.swap_remove(k)))?;
566        let prefill = match is_type {
567            true => attachment
568                .mime
569                .clone()
570                .unwrap_or_else(|| rmut_core::compose::content_type(&attachment.path).to_string()),
571            false => attachment.description.clone().unwrap_or_default(),
572        };
573        Some(Ask::Line {
574            label: match is_type {
575                true => "Content-Type: ".into(),
576                false => "Description: ".into(),
577            },
578            prefill,
579            wants: Wants::Other,
580            what: AskKind::AttachField { k, is_type },
581        })
582    }
583
584    pub fn ask_security(&self) -> Option<Ask> {
585        self.draft()?;
586        Some(Ask::Key {
587            label: "Security: (e)ncrypt (s)ign (b)oth (c)lear: ".into(),
588            what: AskKind::Security,
589        })
590    }
591
592    pub fn ask_postpone(&mut self) -> Option<Ask> {
593        self.draft()?;
594        // mutt's $postpone: yes/no decide without asking, ask-yes /
595        // ask-no ask with the matching default.
596        match self
597            .config
598            .mail
599            .postpone
600            .as_deref()
601            .unwrap_or("ask-yes")
602            .trim()
603            .to_lowercase()
604            .as_str()
605        {
606            "yes" => {
607                if let Some(draft) = self.take_draft() {
608                    self.postpone_draft(draft);
609                }
610                None
611            }
612            "no" => {
613                if let Some(draft) = self.take_draft() {
614                    let _ = std::fs::remove_file(&draft.path);
615                    self.note("message discarded");
616                }
617                None
618            }
619            other => Some(Ask::Key {
620                label: "Postpone this message? (y/n): ".into(),
621                what: AskKind::PostponeAsk {
622                    default_yes: other != "ask-no",
623                },
624            }),
625        }
626    }
627
628    /// mutt's `!`: run something with the display out of the way.
629    pub fn ask_shell(&self) -> Ask {
630        Ask::Line {
631            label: "Shell command: ".into(),
632            prefill: String::new(),
633            wants: Wants::Command,
634            what: AskKind::Shell,
635        }
636    }
637
638    /// Ctrl+Z: ask to be put in the background. A front end that
639    /// cannot be suspended simply does not honour it.
640    pub fn request_suspend(&mut self) {
641        self.requests.push(Request::Suspend);
642    }
643
644    /// Hand back an answer. The next question, when there is one.
645    pub fn answer(&mut self, what: AskKind, answer: Answer<'_>) -> Option<Ask> {
646        match (what, answer) {
647            (AskKind::Limit, Answer::Line(input)) => {
648                self.set_limit(input);
649                None
650            }
651            (AskKind::Search { back }, Answer::Line(input)) => {
652                self.search_rev = back;
653                if !input.is_empty() {
654                    match self.compile_search(input) {
655                        Ok(patterns) => {
656                            self.resolve_body_terms(&patterns);
657                            self.last_search = Some(patterns);
658                        }
659                        Err(err) => {
660                            self.error(format!("bad pattern: {err}"));
661                            return None;
662                        }
663                    }
664                }
665                self.search_next();
666                None
667            }
668            (AskKind::Pattern { op }, Answer::Line(input)) => {
669                self.apply_pattern(input, op.verb(), |m| op.apply(m));
670                None
671            }
672            (
673                AskKind::CopyTo {
674                    delete,
675                    tagged,
676                    decode,
677                },
678                Answer::Line(input),
679            ) => {
680                let input = self.expand_folder(input);
681                // mutt's $confirmappend: adding to a mailbox that is
682                // already there is worth a question.
683                if self.config.mail.confirmappend && mailbox_exists(&input) {
684                    return Some(Ask::Key {
685                        label: format!("Append messages to {input}? (y/n): "),
686                        what: AskKind::AppendConfirm {
687                            input,
688                            delete,
689                            tagged,
690                            decode,
691                        },
692                    });
693                }
694                self.copy_message(&input, delete, tagged, decode);
695                None
696            }
697            (
698                AskKind::AppendConfirm {
699                    input,
700                    delete,
701                    tagged,
702                    decode,
703                },
704                Answer::Key(key),
705            ) => {
706                // ask-yes, like mutt's: Enter takes the yes.
707                if matches!(key, Key::Char('y') | Key::Enter) {
708                    self.copy_message(&input, delete, tagged, decode);
709                }
710                None
711            }
712            (AskKind::QuitConfirm, Answer::Key(key)) => {
713                let yes = match key {
714                    Key::Char('y') => true,
715                    Key::Char('n') => false,
716                    Key::Enter => self.quit_default,
717                    _ => false,
718                };
719                match yes {
720                    true => self.leave_now(),
721                    false => None,
722                }
723            }
724            (AskKind::Pipe { tagged }, Answer::Line(command)) => {
725                self.pipe_message(command, tagged);
726                None
727            }
728            (AskKind::AliasNick { addr }, Answer::Line(nick)) => {
729                self.create_alias(nick, &addr);
730                None
731            }
732            (AskKind::EditLabel { tagged }, Answer::Line(input)) => {
733                self.edit_label(input, tagged);
734                None
735            }
736            // ---- the compose flow: one question leads to the next
737            (AskKind::ReplyTo, Answer::Key(key)) => match key {
738                Key::Char('y') | Key::Enter => self.answer_reply_to(true),
739                Key::Char('n') => self.answer_reply_to(false),
740                _ => {
741                    self.cancel_setup();
742                    self.note("reply cancelled");
743                    None
744                }
745            },
746            (AskKind::EditHeader { name }, Answer::Line(input)) => {
747                let value = match name.as_str() {
748                    "Subject" => input.to_string(),
749                    _ => rmut_core::alias::expand(
750                        input,
751                        &rmut_core::alias::load(self.config.mail.alias_file.as_deref()),
752                    ),
753                };
754                self.set_draft_header(&name, &value);
755                None
756            }
757            (AskKind::Shell, Answer::Line(command)) => {
758                if !command.is_empty() {
759                    self.requests.push(Request::Shell(command.to_string()));
760                }
761                None
762            }
763            (AskKind::EditFcc, Answer::Line(input)) => {
764                if let Some(draft) = self.draft_mut() {
765                    draft.fcc = Some(input.trim().to_string());
766                }
767                None
768            }
769            (AskKind::AttachFile, Answer::Line(input)) => {
770                self.attach_file(input);
771                None
772            }
773            (AskKind::AttachField { k, is_type }, Answer::Line(input)) => {
774                self.set_attach_field(k, input, is_type);
775                None
776            }
777            (AskKind::Security, Answer::Key(key)) => {
778                if let Some(draft) = self.draft_mut() {
779                    draft.security = match key {
780                        Key::Char('e') => Security::Encrypt,
781                        Key::Char('s') => Security::Sign,
782                        Key::Char('b') => Security::Both,
783                        Key::Char('c') => Security::None,
784                        _ => draft.security,
785                    };
786                }
787                None
788            }
789            (AskKind::PostponeAsk { default_yes }, Answer::Key(key)) => {
790                let postpone = match key {
791                    Key::Char('y') => true,
792                    Key::Char('n') => false,
793                    Key::Enter => default_yes,
794                    // Anything else goes back to the menu.
795                    _ => {
796                        self.requests.push(Request::ShowDraft);
797                        return None;
798                    }
799                };
800                if let Some(draft) = self.take_draft() {
801                    if postpone {
802                        self.postpone_draft(draft);
803                    } else {
804                        let _ = std::fs::remove_file(&draft.path);
805                        self.note("message discarded");
806                    }
807                }
808                None
809            }
810            (AskKind::NoAttach, Answer::Key(key)) => match key {
811                // ask-no: Enter goes back to the menu, where `a`
812                // attaches the file that was forgotten.
813                Key::Char('y') => {
814                    self.confirm_attachment();
815                    self.send_draft()
816                }
817                _ => {
818                    self.note("not sent; a attaches a file");
819                    self.requests.push(Request::ShowDraft);
820                    None
821                }
822            },
823            (AskKind::ComposeTo, Answer::Line(input)) => self.answer_to(input),
824            (AskKind::ComposeCc, Answer::Line(input)) => self.answer_cc(input),
825            (AskKind::ComposeBcc, Answer::Line(input)) => self.answer_bcc(input),
826            (AskKind::ComposeSubject, Answer::Line(input)) => self.answer_subject(input),
827            (AskKind::NoSubject { default_yes }, Answer::Key(key)) => match key {
828                Key::Char('n') => self.answer_subject_kept(),
829                Key::Char('y') => {
830                    self.cancel_setup();
831                    self.error("aborted (no subject)");
832                    None
833                }
834                // ask-yes: Enter aborts, like mutt; ask-no keeps it.
835                Key::Enter if !default_yes => self.answer_subject_kept(),
836                _ => {
837                    self.cancel_setup();
838                    self.error("aborted (no subject)");
839                    None
840                }
841            },
842            (AskKind::IncludeReply { default_yes }, Answer::Key(key)) => match key {
843                Key::Char('n') => self.answer_include(false),
844                Key::Char('y') => self.answer_include(true),
845                Key::Enter => self.answer_include(default_yes),
846                _ => {
847                    self.cancel_setup();
848                    self.note("reply cancelled");
849                    None
850                }
851            },
852            (AskKind::ForwardAttach, Answer::Key(key)) => match key {
853                // ask-yes: Enter takes the attachment.
854                Key::Char('n') => self.answer_forward_attach(false),
855                Key::Char('y') | Key::Enter => self.answer_forward_attach(true),
856                _ => {
857                    self.cancel_setup();
858                    self.note("forward cancelled");
859                    None
860                }
861            },
862            (AskKind::BounceTo { tagged }, Answer::Line(input)) => {
863                let to = rmut_core::alias::expand(
864                    input,
865                    &rmut_core::alias::load(self.config.mail.alias_file.as_deref()),
866                );
867                if to.trim().is_empty() {
868                    self.note("no recipients, bounce cancelled");
869                    return None;
870                }
871                let n = self.op_targets(tagged).len();
872                Some(Ask::Key {
873                    label: match n {
874                        1 => format!("Bounce message to {to}? (y/n): "),
875                        _ => format!("Bounce {n} messages to {to}? (y/n): "),
876                    },
877                    what: AskKind::BounceConfirm { to, tagged },
878                })
879            }
880            (AskKind::BounceConfirm { to, tagged }, Answer::Key(key)) => {
881                if key == Key::Char('y') {
882                    self.bounce_current(&to, tagged);
883                }
884                None
885            }
886            (
887                AskKind::PrintConfirm {
888                    tagged,
889                    default_yes,
890                },
891                Answer::Key(key),
892            ) => {
893                if key == Key::Char('y') || (default_yes && key == Key::Enter) {
894                    self.print_current(tagged);
895                }
896                None
897            }
898            (AskKind::Purge { quit }, Answer::Key(key)) => {
899                // ask-yes, like mutt's $delete: Enter takes the yes.
900                // n writes flag changes but keeps the messages marked
901                // deleted; anything else calls the whole thing off,
902                // including the quit that asked.
903                if matches!(key, Key::Char('y') | Key::Char('n') | Key::Enter) {
904                    self.sync(key != Key::Char('n'));
905                    if quit {
906                        self.requests.push(Request::Quit);
907                    }
908                }
909                None
910            }
911            (AskKind::Sort, Answer::Key(key)) => {
912                let (sort, rev) = match key {
913                    Key::Char('d') => (SortKey::Date, false),
914                    Key::Char('D') => (SortKey::Date, true),
915                    Key::Char('f') => (SortKey::From, false),
916                    Key::Char('F') => (SortKey::From, true),
917                    Key::Char('s') => (SortKey::Subject, false),
918                    Key::Char('S') => (SortKey::Subject, true),
919                    Key::Char('z') => (SortKey::Size, false),
920                    Key::Char('Z') => (SortKey::Size, true),
921                    Key::Char('t') | Key::Char('T') => (SortKey::Threads, false),
922                    Key::Char('o') => (SortKey::To, false),
923                    Key::Char('O') => (SortKey::To, true),
924                    Key::Char('y') => (SortKey::Label, false),
925                    Key::Char('Y') => (SortKey::Label, true),
926                    Key::Char('u') => (SortKey::Unsorted, false),
927                    Key::Char('U') => (SortKey::Unsorted, true),
928                    _ => return None,
929                };
930                self.sort = sort;
931                self.sort_rev = rev;
932                self.apply_sort();
933                self.note(format!(
934                    "sorted by {}{}",
935                    sort.name(),
936                    if rev { " (reverse)" } else { "" }
937                ));
938                None
939            }
940            // A key answer to a line question, or the other way round:
941            // nothing to do with it.
942            _ => None,
943        }
944    }
945
946    /// Anything the session wants the front end to do, oldest first.
947    pub fn take_request(&mut self) -> Option<Request> {
948        match self.requests.is_empty() {
949            true => None,
950            false => Some(self.requests.remove(0)),
951        }
952    }
953
954    /// mutt's `+x` / `=x`: a mailbox under $folder.
955    fn expand_folder(&self, input: &str) -> String {
956        rmut_core::config::expand_folder(input, self.config.mail.folder.as_deref())
957    }
958
959    /// The limit pattern, or none of it when the answer is empty or
960    /// mutt's "all".
961    fn set_limit(&mut self, input: &str) {
962        let keep = self.selected_path();
963        if input.is_empty() || input == "all" {
964            self.limit = None;
965        } else {
966            match self.compile_search(input) {
967                Ok(patterns) => {
968                    self.resolve_body_terms(&patterns);
969                    self.limit = Some((input.to_string(), patterns));
970                }
971                Err(err) => {
972                    self.error(format!("bad pattern: {err}"));
973                    return;
974                }
975            }
976        }
977        self.rebuild_visible(keep);
978        if self.visible.is_empty() {
979            self.note("no messages match the limit");
980        }
981    }
982}