Skip to main content

rmut_session/
drafts.rs

1//! Starting a draft: the questions between a key and an editor.
2//!
3//! mutt asks its way into a message: reply to the Reply-To address or
4//! the From one, to whom, about what, quote the original or not. Every
5//! one of those is an [`Ask`], so the flow lives here rather than in
6//! whatever is drawing the prompts, and it ends by handing the front
7//! end a draft to open an editor on.
8
9use rmut_core::{alias, compose, message};
10
11use crate::{
12    Ask, AskKind, Compose, ComposeBase, ComposeKind, ComposeSetup, Request, Session, Wants,
13};
14
15impl Session {
16    /// Start a draft: new, a reply, or a forward. The first question
17    /// comes back, or none when nothing needs asking.
18    pub fn start_compose(&mut self, kind: ComposeKind) -> Option<Ask> {
19        let base = match kind {
20            ComposeKind::New => None,
21            _ => match self.compose_base() {
22                Some(b) => Some(b),
23                None => {
24                    self.error("no message selected");
25                    return None;
26                }
27            },
28        };
29        self.continue_setup(kind, base)
30    }
31
32    /// `L`: reply to the mailing list. Refuses when the message names
33    /// no list rmut knows of, rather than quietly replying to the
34    /// author, which is the mistake list-reply exists to prevent.
35    pub fn start_list_reply(&mut self) -> Option<Ask> {
36        let Some(base) = self.compose_base() else {
37            self.error("no message selected");
38            return None;
39        };
40        if self.list_target(&base).is_none() {
41            self.error(match self.lists.is_empty() {
42                true => "no mailing lists configured (mail.lists / mail.subscribed)",
43                false => "not a message from a known mailing list",
44            });
45            return None;
46        }
47        self.continue_setup(ComposeKind::ListReply, Some(base))
48    }
49
50    fn continue_setup(&mut self, kind: ComposeKind, base: Option<ComposeBase>) -> Option<Ask> {
51        // mutt's $autoedit (with edit_headers): no prompts, no
52        // questions: the defaults land in the draft and the editor
53        // opens; everything stays editable there and in the menu.
54        if self.config.mail.autoedit && self.edit_headers() {
55            let to = match (&kind, &base) {
56                (ComposeKind::Reply | ComposeKind::GroupReply, Some(b)) => b.reply_to.clone(),
57                (ComposeKind::ListReply, Some(b)) => self.list_target(b).unwrap_or_default(),
58                _ => String::new(),
59            };
60            let subject = match (&kind, &base) {
61                (
62                    ComposeKind::Reply | ComposeKind::GroupReply | ComposeKind::ListReply,
63                    Some(b),
64                ) => self.reply_subject(&b.subject),
65                (ComposeKind::Forward, Some(b)) => self.forward_subject(b),
66                _ => String::new(),
67            };
68            self.setup = Some(ComposeSetup {
69                kind,
70                base,
71                to: Some(to),
72                cc: None,
73                bcc: None,
74                subject_prefill: None,
75                subject: None,
76                fwd_attach: None,
77            });
78            self.finish_compose_setup(&subject, true);
79            return None;
80        }
81        let ask_reply_to = matches!(kind, ComposeKind::Reply | ComposeKind::GroupReply)
82            && base.as_ref().is_some_and(|b| b.has_reply_to);
83        self.setup = Some(ComposeSetup {
84            kind,
85            base,
86            to: None,
87            cc: None,
88            bcc: None,
89            subject_prefill: None,
90            subject: None,
91            fwd_attach: None,
92        });
93        if ask_reply_to {
94            // mutt's $reply_to = ask-yes.
95            let addr = self
96                .setup
97                .as_ref()
98                .and_then(|s| s.base.as_ref())
99                .map(|b| b.reply_to.clone())
100                .unwrap_or_default();
101            return Some(Ask::Key {
102                label: format!("Reply to {addr}? (y/n): "),
103                what: AskKind::ReplyTo,
104            });
105        }
106        self.ask_to(true)
107    }
108
109    /// Who the draft goes to, unless $fast_reply says the prefill
110    /// will do.
111    fn ask_to(&mut self, use_reply_to: bool) -> Option<Ask> {
112        let setup = self.setup.as_ref()?;
113        let to_prefill = match setup.kind {
114            ComposeKind::Reply | ComposeKind::GroupReply => setup
115                .base
116                .as_ref()
117                .map(|b| {
118                    if use_reply_to {
119                        b.reply_to.clone()
120                    } else {
121                        b.from_hdr.clone()
122                    }
123                })
124                .unwrap_or_default(),
125            ComposeKind::ListReply => setup
126                .base
127                .as_ref()
128                .and_then(|b| self.list_target(b))
129                .unwrap_or_default(),
130            ComposeKind::New | ComposeKind::Forward => String::new(),
131        };
132        // mutt's $fast_reply: replies take the prefills without the
133        // To and Subject prompts (forwards still need a recipient).
134        if self.config.mail.fast_reply
135            && matches!(
136                setup.kind,
137                ComposeKind::Reply | ComposeKind::GroupReply | ComposeKind::ListReply
138            )
139            && setup.base.is_some()
140        {
141            let subject = setup
142                .base
143                .as_ref()
144                .map(|b| self.reply_subject(&b.subject))
145                .unwrap_or_default();
146            if let Some(setup) = &mut self.setup {
147                setup.to = Some(to_prefill);
148            }
149            return self.subject_submitted(&subject);
150        }
151        Some(Ask::Line {
152            label: "To: ".into(),
153            prefill: to_prefill,
154            wants: Wants::Address,
155            what: AskKind::ComposeTo,
156        })
157    }
158
159    fn setup_to_submitted(&mut self, input: &str) -> Option<Ask> {
160        let to = alias::expand(input, &alias::load(self.config.mail.alias_file.as_deref()));
161        self.setup.as_mut()?.to = Some(to);
162        let setup = self.setup.as_ref()?;
163        let subject_prefill = match (&setup.kind, &setup.base) {
164            (ComposeKind::Reply | ComposeKind::GroupReply | ComposeKind::ListReply, Some(b)) => {
165                self.reply_subject(&b.subject)
166            }
167            (ComposeKind::Forward, Some(b)) => self.forward_subject(b),
168            _ => String::new(),
169        };
170        // What the Subject prompt will offer, parked while the
171        // copies are asked about; $fast_reply skips the prompt when
172        // it gets there.
173        self.setup.as_mut()?.subject_prefill = Some(subject_prefill);
174        self.ask_cc_or_on()
175    }
176
177    /// mutt's $askcc: the copies, prefilled with whatever a group
178    /// reply worked out. Straight on when it is off.
179    fn ask_cc_or_on(&mut self) -> Option<Ask> {
180        if !self.config.mail.ask_cc {
181            return self.ask_bcc_or_on();
182        }
183        let prefill = self.group_cc().unwrap_or_default();
184        Some(Ask::Line {
185            label: "Cc: ".into(),
186            prefill,
187            wants: Wants::Address,
188            what: AskKind::ComposeCc,
189        })
190    }
191
192    /// mutt's $askbcc, which nothing prefills.
193    fn ask_bcc_or_on(&mut self) -> Option<Ask> {
194        if !self.config.mail.ask_bcc {
195            return self.ask_subject();
196        }
197        Some(Ask::Line {
198            label: "Bcc: ".into(),
199            prefill: String::new(),
200            wants: Wants::Address,
201            what: AskKind::ComposeBcc,
202        })
203    }
204
205    /// The Subject prompt, or straight past it when $fast_reply has
206    /// already filled it in.
207    fn ask_subject(&mut self) -> Option<Ask> {
208        let prefill = self.setup.as_mut()?.subject_prefill.take()?;
209        if self.config.mail.fast_reply && !prefill.is_empty() {
210            return self.subject_submitted(&prefill);
211        }
212        Some(Ask::Line {
213            label: "Subject: ".into(),
214            prefill,
215            wants: Wants::Other,
216            what: AskKind::ComposeSubject,
217        })
218    }
219
220    pub(crate) fn answer_cc(&mut self, input: &str) -> Option<Ask> {
221        let cc = alias::expand(input, &alias::load(self.config.mail.alias_file.as_deref()));
222        self.setup.as_mut()?.cc = Some(cc);
223        self.ask_bcc_or_on()
224    }
225
226    pub(crate) fn answer_bcc(&mut self, input: &str) -> Option<Ask> {
227        let bcc = alias::expand(input, &alias::load(self.config.mail.alias_file.as_deref()));
228        self.setup.as_mut()?.bcc = Some(bcc);
229        self.ask_subject()
230    }
231
232    /// mutt's group reply: everyone else on the original, minus who
233    /// is already in To and (unless $metoo) me. None when there is
234    /// nobody left, when this is not a group reply, or when the
235    /// sender named a Mail-Followup-To, which replaces To and leaves
236    /// the copies alone.
237    fn group_cc(&self) -> Option<String> {
238        let setup = self.setup.as_ref()?;
239        if setup.kind != ComposeKind::GroupReply {
240            return None;
241        }
242        let base = setup.base.as_ref()?;
243        if !base.followup_to.trim().is_empty() {
244            return None;
245        }
246        let joined = compose::group_recipients(
247            &base.orig_to,
248            &base.orig_cc,
249            setup.to.as_deref().unwrap_or_default(),
250            self.me(),
251            self.config.mail.metoo,
252        );
253        (!joined.is_empty()).then_some(joined)
254    }
255
256    /// mutt's $indent_string: what each quoted line starts with.
257    fn indent_string(&self) -> &str {
258        self.config
259            .mail
260            .indent_string
261            .as_deref()
262            .unwrap_or(compose::DEFAULT_INDENT)
263    }
264
265    /// mutt's $signature: the text this draft ends with, read (or run)
266    /// afresh for every draft, so a generated one can say something
267    /// new each time.
268    fn signature(&self) -> Option<String> {
269        compose::signature_text(self.config.mail.signature.as_deref()?)
270    }
271
272    /// mutt's $sig_dashes: on unless turned off, as in mutt.
273    fn sig_dashes(&self) -> bool {
274        self.config.mail.sig_dashes.unwrap_or(true)
275    }
276
277    /// After the Subject prompt: mutt's $abort_nosubject on an empty
278    /// subject, then on replies mutt's $include (ask-yes).
279    fn subject_submitted(&mut self, input: &str) -> Option<Ask> {
280        if input.trim().is_empty() {
281            // mutt's quadoption: the ask forms differ only in what
282            // Enter takes, and the other two answer it themselves.
283            match self
284                .config
285                .mail
286                .abort_nosubject
287                .as_deref()
288                .unwrap_or("ask-yes")
289            {
290                "no" => return self.subject_ready(String::new()),
291                "yes" => {
292                    self.cancel_setup();
293                    self.error("aborted (no subject)");
294                    return None;
295                }
296                quad => {
297                    return Some(Ask::Key {
298                        label: "No subject, abort? (y/n): ".into(),
299                        what: AskKind::NoSubject {
300                            default_yes: quad != "ask-no",
301                        },
302                    });
303                }
304            }
305        }
306        self.subject_ready(input.to_string())
307    }
308
309    fn subject_ready(&mut self, subject: String) -> Option<Ask> {
310        let is_reply = self.setup.as_ref().is_some_and(|s| {
311            matches!(
312                s.kind,
313                ComposeKind::Reply | ComposeKind::GroupReply | ComposeKind::ListReply
314            ) && s.base.is_some()
315        });
316        let ask_fwd = self.config.mail.forward.as_deref() == Some("ask")
317            && self
318                .setup
319                .as_ref()
320                .is_some_and(|s| s.kind == ComposeKind::Forward && s.base.is_some());
321        if is_reply {
322            // mutt's $include: "yes" and "no" decide it, the two
323            // ask forms ask, and Enter takes the one they name.
324            match self.config.mail.include.as_deref().unwrap_or("ask-yes") {
325                "yes" => {
326                    self.finish_compose_setup(&subject, true);
327                    return None;
328                }
329                "no" => {
330                    self.finish_compose_setup(&subject, false);
331                    return None;
332                }
333                include => {
334                    if let Some(setup) = &mut self.setup {
335                        setup.subject = Some(subject);
336                    }
337                    return Some(Ask::Key {
338                        label: "Include message in reply? (y/n): ".into(),
339                        what: AskKind::IncludeReply {
340                            default_yes: include != "ask-no",
341                        },
342                    });
343                }
344            }
345        }
346        if ask_fwd {
347            // mime_forward = "ask": whole original vs inline quote.
348            if let Some(setup) = &mut self.setup {
349                setup.subject = Some(subject);
350            }
351            return Some(Ask::Key {
352                label: "Forward as attachment? (y/n): ".into(),
353                what: AskKind::ForwardAttach,
354            });
355        }
356        self.finish_compose_setup(&subject, true);
357        None
358    }
359
360    fn finish_compose_setup(&mut self, subject: &str, include: bool) {
361        let Some(setup) = self.setup.take() else {
362            return;
363        };
364        // mutt's reply-hook: in force while this reply's draft is
365        // built, so `set from`, edit_headers and my_hdr all see it.
366        let reply_hooks = self.apply_reply_hooks(setup.base.as_ref(), setup.kind);
367        self.finish_compose_draft(setup, subject, include);
368        self.restore_after_reply_hooks(reply_hooks);
369    }
370
371    fn finish_compose_draft(&mut self, setup: ComposeSetup, subject: &str, include: bool) {
372        let asked_cc = setup.cc.clone().filter(|cc| !cc.trim().is_empty());
373        let bcc = setup.bcc.clone().filter(|bcc| !bcc.trim().is_empty());
374        let mut to = setup.to.unwrap_or_default();
375        let mut cc = None;
376        let mut in_reply_to = None;
377        let mut references = None;
378        let mut body = String::new();
379        let mut attach = None;
380        if let Some(b) = &setup.base {
381            match setup.kind {
382                ComposeKind::Reply | ComposeKind::GroupReply | ComposeKind::ListReply => {
383                    if include {
384                        let orig = message::body_text(&b.path).unwrap_or_default();
385                        let quoted = self.quoted_of(b);
386                        let attribution = compose::attribution(
387                            self.config
388                                .mail
389                                .attribution
390                                .as_deref()
391                                .unwrap_or(compose::DEFAULT_ATTRIBUTION),
392                            &quoted,
393                        );
394                        body = compose::quote(&attribution, self.indent_string(), &orig);
395                    }
396                    in_reply_to = b.msg_id.clone();
397                    let mut refs = b.references.clone();
398                    if let Some(id) = &b.msg_id
399                        && !refs.contains(id)
400                    {
401                        refs.push(id.clone());
402                    }
403                    if !refs.is_empty() {
404                        references = Some(refs.join(" "));
405                    }
406                    if setup.kind == ComposeKind::GroupReply {
407                        // mutt honors a sender's Mail-Followup-To: it
408                        // is exactly the recipient set they asked for,
409                        // so it replaces To and leaves Cc alone.
410                        if !b.followup_to.trim().is_empty() {
411                            to = b.followup_to.trim().to_string();
412                        } else {
413                            // Everyone else on the original, minus the
414                            // recipient already in To and (mutt's
415                            // $metoo off) my own addresses: replying to
416                            // all should not mail me a copy.
417                            let joined = compose::group_recipients(
418                                &b.orig_to,
419                                &b.orig_cc,
420                                &to,
421                                self.me(),
422                                self.config.mail.metoo,
423                            );
424                            if !joined.is_empty() {
425                                cc = Some(joined);
426                            }
427                        }
428                    }
429                }
430                ComposeKind::Forward
431                    if setup.fwd_attach.unwrap_or_else(|| self.forward_attaches()) =>
432                {
433                    // The original goes along whole; nothing to quote.
434                    attach = Some(b.path.clone());
435                }
436                ComposeKind::Forward => {
437                    let orig = message::body_text(&b.path).unwrap_or_default();
438                    // mutt's $forward_quote: the original comes in
439                    // quoted, so a reply to the forward reads right.
440                    let indent = self.config.mail.forward_quote.then(|| self.indent_string());
441                    body =
442                        compose::forward_body(&b.from_display, b.date, &b.subject, &orig, indent);
443                }
444                ComposeKind::New => {}
445            }
446        }
447        // mutt's $signature closes every draft it starts, quoted
448        // original or not; $sig_on_top puts it above the quote.
449        if let Some(sig) = self.signature() {
450            let on_top = self.config.mail.sig_on_top.unwrap_or(false);
451            body = compose::with_signature_at(&body, &sig, self.sig_dashes(), on_top);
452        }
453        // An answered Cc ($askcc) is what the user said, over
454        // whatever the group reply worked out.
455        let cc = asked_cc.or(cc);
456        let from = self.compose_from(setup.base.as_ref(), &to);
457        let followup =
458            self.followup_header(&to, cc.as_deref(), from.as_deref().unwrap_or_default());
459        let text = compose::draft_text(
460            &compose::DraftHeaders {
461                from,
462                to,
463                cc,
464                subject: subject.to_string(),
465                in_reply_to,
466                references,
467            },
468            &body,
469        );
470        // DraftHeaders has no Mail-Followup-To or Bcc slot; both go
471        // ahead of the blank line, where edit_headers shows them like
472        // any other header.
473        let mut extra: Vec<String> = Vec::new();
474        if let Some(value) = followup {
475            extra.push(format!("Mail-Followup-To: {value}"));
476        }
477        if let Some(value) = bcc {
478            extra.push(format!("Bcc: {value}"));
479        }
480        let text = match extra.is_empty() {
481            true => text,
482            false => match text.split_once("\n\n") {
483                Some((head, rest)) => format!("{head}\n{}\n\n{rest}", extra.join("\n")),
484                None => text,
485            },
486        };
487        match self.stage_draft(&text) {
488            Ok((path, hidden_head)) => {
489                // What the editor is being handed, for mutt's
490                // $abort_unmodified when it hands it straight back.
491                let staged = std::fs::read_to_string(&path).unwrap_or_default();
492                self.staged = Some((path.clone(), staged));
493                let security = self.security_for(&setup.kind, setup.base.as_ref());
494                self.requests.push(Request::Editor(Compose {
495                    path,
496                    recall_source: None,
497                    security,
498                    attach,
499                    hidden_head,
500                    fcc: None,
501                }));
502            }
503            Err(err) => self.error(format!("cannot write draft: {err:#}")),
504        }
505    }
506
507    /// The Reply-To question is answered: on to the recipient.
508    pub(crate) fn answer_reply_to(&mut self, use_reply_to: bool) -> Option<Ask> {
509        self.ask_to(use_reply_to)
510    }
511
512    pub(crate) fn answer_to(&mut self, input: &str) -> Option<Ask> {
513        self.setup_to_submitted(input)
514    }
515
516    pub(crate) fn answer_subject(&mut self, input: &str) -> Option<Ask> {
517        self.subject_submitted(input)
518    }
519
520    /// mutt's $abort_nosubject answered "no, send it anyway".
521    pub(crate) fn answer_subject_kept(&mut self) -> Option<Ask> {
522        self.subject_ready(String::new())
523    }
524
525    pub(crate) fn answer_include(&mut self, include: bool) -> Option<Ask> {
526        let subject = self.parked_subject();
527        self.finish_compose_setup(&subject, include);
528        None
529    }
530
531    pub(crate) fn answer_forward_attach(&mut self, attach: bool) -> Option<Ask> {
532        let subject = self.parked_subject();
533        if let Some(setup) = &mut self.setup {
534            setup.fwd_attach = Some(attach);
535        }
536        self.finish_compose_setup(&subject, true);
537        None
538    }
539
540    /// The subject parked while a question was up.
541    fn parked_subject(&mut self) -> String {
542        self.setup
543            .as_mut()
544            .and_then(|s| s.subject.take())
545            .unwrap_or_default()
546    }
547
548    /// The message a reply or a forward is about, for the format
549    /// strings that describe it.
550    fn quoted_of<'a>(&self, base: &'a ComposeBase) -> compose::Quoted<'a> {
551        compose::Quoted {
552            from: &base.from_hdr,
553            subject: &base.subject,
554            message_id: base.msg_id.as_deref(),
555            date: base.date,
556        }
557    }
558
559    /// mutt's $forward_format over the message being forwarded.
560    fn forward_subject(&self, base: &ComposeBase) -> String {
561        compose::forward_subject(
562            self.config
563                .mail
564                .forward_format
565                .as_deref()
566                .unwrap_or(compose::DEFAULT_FORWARD_FORMAT),
567            &self.quoted_of(base),
568        )
569    }
570
571    /// Give up on the draft that was being set up.
572    pub fn cancel_setup(&mut self) {
573        self.setup = None;
574    }
575}