Skip to main content

rmut_session/
ask.rs

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