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        // Y and N are y and n, as in mutt (its yes/no is the locale's
783        // ^[yY]), and Caps Lock does not call a question off. The
784        // sort question alone reads case: uppercase sorts reversed.
785        let answer = match answer {
786            Answer::Key(Key::Char(c)) if !matches!(what, AskKind::Sort) => {
787                Answer::Key(Key::Char(c.to_ascii_lowercase()))
788            }
789            other => other,
790        };
791        match (what, answer) {
792            (AskKind::Limit, Answer::Line(input)) => {
793                self.set_limit(input);
794                None
795            }
796            (AskKind::Search { back }, Answer::Line(input)) => {
797                self.search_rev = back;
798                if !input.is_empty() {
799                    match self.compile_search(input) {
800                        Ok(patterns) => {
801                            let redo = input.to_string();
802                            if !self.body_terms_ready(
803                                &patterns,
804                                Box::new(move |session| {
805                                    session.answer(AskKind::Search { back }, Answer::Line(&redo));
806                                }),
807                            ) {
808                                return None;
809                            }
810                            self.last_search = Some(patterns);
811                        }
812                        Err(err) => {
813                            self.error(format!("bad pattern: {err}"));
814                            return None;
815                        }
816                    }
817                }
818                self.search_next();
819                None
820            }
821            (AskKind::Pattern { op }, Answer::Line(input)) => {
822                self.apply_pattern(input, op);
823                None
824            }
825            (
826                AskKind::CopyTo {
827                    delete,
828                    tagged,
829                    decode,
830                },
831                Answer::Line(input),
832            ) => {
833                let input = self.expand_folder(input);
834                // mutt's $confirmappend: adding to a mailbox that is
835                // already there is worth a question.
836                if self.config.mail.confirmappend && mailbox_exists(&input) {
837                    return Some(Ask::Key {
838                        label: format!("Append messages to {input}? (y/n): "),
839                        what: AskKind::AppendConfirm {
840                            input,
841                            delete,
842                            tagged,
843                            decode,
844                        },
845                    });
846                }
847                self.copy_message(&input, delete, tagged, decode);
848                None
849            }
850            (
851                AskKind::AppendConfirm {
852                    input,
853                    delete,
854                    tagged,
855                    decode,
856                },
857                Answer::Key(key),
858            ) => {
859                // ask-yes, like mutt's: Enter takes the yes.
860                if matches!(key, Key::Char('y') | Key::Enter) {
861                    self.copy_message(&input, delete, tagged, decode);
862                }
863                None
864            }
865            (AskKind::QuitConfirm, Answer::Key(key)) => {
866                let yes = match key {
867                    Key::Char('y') => true,
868                    Key::Char('n') => false,
869                    Key::Enter => self.quit_default,
870                    _ => false,
871                };
872                match yes {
873                    true => self.leave_now(),
874                    false => None,
875                }
876            }
877            (AskKind::Pipe { tagged }, Answer::Line(command)) => {
878                self.pipe_message(command, tagged);
879                None
880            }
881            (AskKind::AliasNick { addr }, Answer::Line(nick)) => {
882                self.create_alias(nick, &addr);
883                None
884            }
885            (AskKind::EditLabel { tagged }, Answer::Line(input)) => {
886                self.edit_label(input, tagged);
887                None
888            }
889            // ---- the compose flow: one question leads to the next
890            (AskKind::ReplyTo, Answer::Key(key)) => match key {
891                Key::Char('y') | Key::Enter => self.answer_reply_to(true),
892                Key::Char('n') => self.answer_reply_to(false),
893                _ => {
894                    self.cancel_setup();
895                    self.note("reply cancelled");
896                    None
897                }
898            },
899            (AskKind::EditHeader { name }, Answer::Line(input)) => {
900                let value = match name.as_str() {
901                    "Subject" => input.to_string(),
902                    _ => rmut_core::alias::expand(
903                        input,
904                        &rmut_core::alias::load(self.config.mail.alias_file.as_deref()),
905                    ),
906                };
907                self.set_draft_header(&name, &value);
908                None
909            }
910            (AskKind::Shell, Answer::Line(command)) => {
911                // Empty is mutt's bare `!`: an interactive $shell.
912                {
913                    self.requests.push(Request::Shell(command.to_string()));
914                }
915                None
916            }
917            (AskKind::EditFcc, Answer::Line(input)) => {
918                if let Some(draft) = self.draft_mut() {
919                    draft.fcc = Some(input.trim().to_string());
920                }
921                None
922            }
923            (AskKind::AttachFile, Answer::Line(input)) => {
924                self.attach_file(input);
925                None
926            }
927            (AskKind::AttachField { k, is_type }, Answer::Line(input)) => {
928                self.set_attach_field(k, input, is_type);
929                None
930            }
931            (AskKind::RenameAttachment { k }, Answer::Line(input)) => {
932                let name = input.trim().to_string();
933                self.edit_attachment(k, |a| {
934                    // The file's own name is no override.
935                    let own = a.path.file_name().and_then(|n| n.to_str()).unwrap_or("");
936                    a.name = (!name.is_empty() && name != own).then(|| name.clone());
937                });
938                self.requests.push(Request::ShowDraft);
939                None
940            }
941            (AskKind::NewMimeFile, Answer::Line(input)) => {
942                let path = input.trim().to_string();
943                if path.is_empty() {
944                    self.requests.push(Request::ShowDraft);
945                    return None;
946                }
947                Some(Ask::Line {
948                    label: "Content-Type: ".into(),
949                    prefill: String::new(),
950                    wants: Wants::Other,
951                    what: AskKind::NewMimeType { path },
952                })
953            }
954            (AskKind::NewMimeType { path }, Answer::Line(input)) => {
955                self.new_mime(&path, input.trim());
956                None
957            }
958            (AskKind::WriteFcc, Answer::Line(input)) => {
959                let mailbox = input.trim().to_string();
960                if !mailbox.is_empty() {
961                    self.write_draft_to(&mailbox);
962                }
963                self.requests.push(Request::ShowDraft);
964                None
965            }
966            (AskKind::Security, Answer::Key(key)) => {
967                if let Some(draft) = self.draft_mut() {
968                    draft.security = match key {
969                        Key::Char('e') => Security::Encrypt,
970                        Key::Char('s') => Security::Sign,
971                        Key::Char('b') => Security::Both,
972                        Key::Char('c') => Security::None,
973                        _ => draft.security,
974                    };
975                }
976                None
977            }
978            (AskKind::PostponeAsk { default_yes }, Answer::Key(key)) => {
979                let postpone = match key {
980                    Key::Char('y') => true,
981                    Key::Char('n') => false,
982                    Key::Enter => default_yes,
983                    // Anything else goes back to the menu.
984                    _ => {
985                        self.requests.push(Request::ShowDraft);
986                        return None;
987                    }
988                };
989                if let Some(draft) = self.take_draft() {
990                    if postpone {
991                        self.postpone_draft(draft);
992                    } else {
993                        let _ = std::fs::remove_file(&draft.path);
994                        self.note("message discarded");
995                    }
996                }
997                None
998            }
999            (AskKind::FccAttach { default_yes }, Answer::Key(key)) => {
1000                let keep = match key {
1001                    Key::Char('y') => true,
1002                    Key::Char('n') => false,
1003                    Key::Enter => default_yes,
1004                    _ => {
1005                        self.note("not sent");
1006                        self.requests.push(Request::ShowDraft);
1007                        return None;
1008                    }
1009                };
1010                self.fcc_attach_answer = Some(keep);
1011                self.send_draft()
1012            }
1013            (AskKind::NoAttach, Answer::Key(key)) => match key {
1014                // ask-no: Enter goes back to the menu, where `a`
1015                // attaches the file that was forgotten.
1016                Key::Char('y') => {
1017                    self.confirm_attachment();
1018                    self.send_draft()
1019                }
1020                _ => {
1021                    self.note("not sent; a attaches a file");
1022                    self.requests.push(Request::ShowDraft);
1023                    None
1024                }
1025            },
1026            (AskKind::ComposeTo, Answer::Line(input)) => self.answer_to(input),
1027            (AskKind::ComposeCc, Answer::Line(input)) => self.answer_cc(input),
1028            (AskKind::ComposeBcc, Answer::Line(input)) => self.answer_bcc(input),
1029            (AskKind::ComposeSubject, Answer::Line(input)) => self.answer_subject(input),
1030            (AskKind::NoSubject { default_yes }, Answer::Key(key)) => match key {
1031                Key::Char('n') => self.answer_subject_kept(),
1032                Key::Char('y') => {
1033                    self.cancel_setup();
1034                    self.error("aborted (no subject)");
1035                    None
1036                }
1037                // ask-yes: Enter aborts, like mutt; ask-no keeps it.
1038                Key::Enter if !default_yes => self.answer_subject_kept(),
1039                _ => {
1040                    self.cancel_setup();
1041                    self.error("aborted (no subject)");
1042                    None
1043                }
1044            },
1045            (AskKind::IncludeReply { default_yes }, Answer::Key(key)) => match key {
1046                Key::Char('n') => self.answer_include(false),
1047                Key::Char('y') => self.answer_include(true),
1048                Key::Enter => self.answer_include(default_yes),
1049                _ => {
1050                    self.cancel_setup();
1051                    self.note("reply cancelled");
1052                    None
1053                }
1054            },
1055            (AskKind::ForwardAttach, Answer::Key(key)) => match key {
1056                // ask-yes: Enter takes the attachment.
1057                Key::Char('n') => self.answer_forward_attach(false),
1058                Key::Char('y') | Key::Enter => self.answer_forward_attach(true),
1059                _ => {
1060                    self.cancel_setup();
1061                    self.note("forward cancelled");
1062                    None
1063                }
1064            },
1065            (AskKind::ForwardEdit { default_yes }, Answer::Key(key)) => {
1066                self.answer_forward_edit(match key {
1067                    Key::Char('y') => Some(true),
1068                    Key::Char('n') => Some(false),
1069                    Key::Enter => Some(default_yes),
1070                    _ => None,
1071                })
1072            }
1073            (AskKind::BounceTo { tagged }, Answer::Line(input)) => {
1074                let to = rmut_core::alias::expand(
1075                    input,
1076                    &rmut_core::alias::load(self.config.mail.alias_file.as_deref()),
1077                );
1078                if to.trim().is_empty() {
1079                    self.note("no recipients, bounce cancelled");
1080                    return None;
1081                }
1082                let n = self.op_targets(tagged).len();
1083                Some(Ask::Key {
1084                    label: match n {
1085                        1 => format!("Bounce message to {to}? (y/n): "),
1086                        _ => format!("Bounce {n} messages to {to}? (y/n): "),
1087                    },
1088                    what: AskKind::BounceConfirm { to, tagged },
1089                })
1090            }
1091            (AskKind::BounceConfirm { to, tagged }, Answer::Key(key)) => {
1092                if key == Key::Char('y') {
1093                    self.bounce_current(&to, tagged);
1094                }
1095                None
1096            }
1097            (
1098                AskKind::PrintConfirm {
1099                    tagged,
1100                    default_yes,
1101                },
1102                Answer::Key(key),
1103            ) => {
1104                if key == Key::Char('y') || (default_yes && key == Key::Enter) {
1105                    self.print_current(tagged);
1106                }
1107                None
1108            }
1109            (AskKind::Purge { quit }, Answer::Key(key)) => {
1110                // ask-yes, like mutt's $delete: Enter takes the yes.
1111                // n writes flag changes but keeps the messages marked
1112                // deleted; anything else calls the whole thing off,
1113                // including the quit that asked.
1114                if matches!(key, Key::Char('y') | Key::Char('n') | Key::Enter) {
1115                    self.sync(key != Key::Char('n'));
1116                    if quit {
1117                        self.requests.push(Request::Quit);
1118                    }
1119                }
1120                None
1121            }
1122            (AskKind::MarkMessage { msg_id }, Answer::Line(input)) => {
1123                let stroke = input.trim();
1124                if stroke.is_empty() {
1125                    return None;
1126                }
1127                // The id without its brackets (a `<` would read as a
1128                // key name in the sequence), regex-quoted for ~i.
1129                let id: String = msg_id
1130                    .trim_matches(|c| c == '<' || c == '>')
1131                    .chars()
1132                    .flat_map(|c| match c {
1133                        '.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '^'
1134                        | '$' | '\\' => vec!['\\', c],
1135                        c => vec![c],
1136                    })
1137                    .collect();
1138                self.requests
1139                    .push(Request::Command(rmut_core::command::Command::Macro {
1140                        menu: rmut_core::command::Menu::Index,
1141                        key: stroke.to_string(),
1142                        seq: format!("/~i {id}<enter>"),
1143                    }));
1144                self.note(format!("Message bound to {stroke}."));
1145                None
1146            }
1147            (AskKind::ListAction { actions }, Answer::Key(key)) => {
1148                let Key::Char(c) = key else {
1149                    return None;
1150                };
1151                let (name, url) = actions
1152                    .iter()
1153                    .find(|(name, _)| name.starts_with(c.to_ascii_uppercase()))?;
1154                match url {
1155                    None => self.error(format!("No list action available for {name}.")),
1156                    Some(url) if !url.to_ascii_lowercase().starts_with("mailto:") => {
1157                        self.error("List actions only support mailto: URIs. (Try a browser?)");
1158                    }
1159                    Some(url) => match rmut_core::mailto::parse(url) {
1160                        Some(mailto) => self.requests.push(Request::Mailto(mailto)),
1161                        None => self.error("Could not parse mailto: URI."),
1162                    },
1163                }
1164                None
1165            }
1166            (AskKind::Sort, Answer::Key(key)) => {
1167                let (sort, rev) = match key {
1168                    Key::Char('d') => (SortKey::Date, false),
1169                    Key::Char('D') => (SortKey::Date, true),
1170                    Key::Char('f') => (SortKey::From, false),
1171                    Key::Char('F') => (SortKey::From, true),
1172                    Key::Char('s') => (SortKey::Subject, false),
1173                    Key::Char('S') => (SortKey::Subject, true),
1174                    Key::Char('z') => (SortKey::Size, false),
1175                    Key::Char('Z') => (SortKey::Size, true),
1176                    Key::Char('t') | Key::Char('T') => (SortKey::Threads, false),
1177                    Key::Char('o') => (SortKey::To, false),
1178                    Key::Char('O') => (SortKey::To, true),
1179                    Key::Char('y') => (SortKey::Label, false),
1180                    Key::Char('Y') => (SortKey::Label, true),
1181                    Key::Char('u') => (SortKey::Unsorted, false),
1182                    Key::Char('U') => (SortKey::Unsorted, true),
1183                    _ => return None,
1184                };
1185                self.sort = sort;
1186                self.sort_rev = rev;
1187                self.apply_sort();
1188                self.note(format!(
1189                    "sorted by {}{}",
1190                    sort.name(),
1191                    if rev { " (reverse)" } else { "" }
1192                ));
1193                None
1194            }
1195            // A key answer to a line question, or the other way round:
1196            // nothing to do with it.
1197            _ => None,
1198        }
1199    }
1200
1201    /// Anything the session wants the front end to do, oldest first.
1202    pub fn take_request(&mut self) -> Option<Request> {
1203        match self.requests.is_empty() {
1204            true => None,
1205            false => Some(self.requests.remove(0)),
1206        }
1207    }
1208
1209    /// mutt's `+x` / `=x`: a mailbox under $folder.
1210    fn expand_folder(&self, input: &str) -> String {
1211        rmut_core::config::expand_folder(input, self.config.mail.folder.as_deref())
1212    }
1213
1214    /// The limit pattern, or none of it when the answer is empty or
1215    /// mutt's "all".
1216    fn set_limit(&mut self, input: &str) {
1217        let keep = self.selected_path();
1218        if input.is_empty() || input == "all" {
1219            self.limit = None;
1220        } else {
1221            match self.compile_search(input) {
1222                Ok(patterns) => {
1223                    let redo = input.to_string();
1224                    if !self.body_terms_ready(
1225                        &patterns,
1226                        Box::new(move |session| session.set_limit(&redo)),
1227                    ) {
1228                        return;
1229                    }
1230                    self.limit = Some((input.to_string(), patterns));
1231                }
1232                Err(err) => {
1233                    self.error(format!("bad pattern: {err}"));
1234                    return;
1235                }
1236            }
1237        }
1238        self.rebuild_visible(keep);
1239        if self.visible.is_empty() {
1240            self.note("no messages match the limit");
1241        }
1242    }
1243}