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 std::path::Path;
10
11use anyhow::Result;
12use rmut_core::{alias, compose, message};
13
14use crate::{
15    Ask, AskKind, Compose, ComposeBase, ComposeKind, ComposeSetup, Request, Session, Wants,
16};
17
18impl Session {
19    /// Start a draft: new, a reply, or a forward. The first question
20    /// comes back, or none when nothing needs asking.
21    pub fn start_compose(&mut self, kind: ComposeKind) -> Option<Ask> {
22        let base = match kind {
23            ComposeKind::New => None,
24            _ => match self.compose_base() {
25                Some(b) => Some(b),
26                None => {
27                    self.error("no message selected");
28                    return None;
29                }
30            },
31        };
32        self.continue_setup(kind, base, None)
33    }
34
35    /// `L`: reply to the mailing list. Refuses when the message names
36    /// no list rmut knows of, rather than quietly replying to the
37    /// author, which is the mistake list-reply exists to prevent.
38    pub fn start_list_reply(&mut self) -> Option<Ask> {
39        let Some(base) = self.compose_base() else {
40            self.error("no message selected");
41            return None;
42        };
43        if self.list_target(&base).is_none() {
44            self.error(match self.lists.is_empty() {
45                true => "no mailing lists configured (mail.lists / mail.subscribed)",
46                false => "not a message from a known mailing list",
47            });
48            return None;
49        }
50        self.continue_setup(ComposeKind::ListReply, Some(base), None)
51    }
52
53    /// mutt's forward-message in the attachment menu, for the part at
54    /// `index` of the selected message: a part that reads as text is
55    /// quoted the way a forward quotes a body, and one that does not
56    /// is attached, as mutt's $mime_forward_rest has it. With that
57    /// off, such a part is refused rather than sent as an empty
58    /// forward. forward = "attach" (mutt's $mime_forward) attaches
59    /// the part whatever it is.
60    pub fn start_forward_part(&mut self, index: usize) -> Option<Ask> {
61        let Some(base) = self.compose_base() else {
62            self.error("no message selected");
63            return None;
64        };
65        let part = match message::parts(&base.path) {
66            Ok(parts) => parts.into_iter().nth(index)?,
67            Err(err) => {
68                self.error(format!("cannot list parts: {err:#}"));
69                return None;
70            }
71        };
72        if !self.part_reads_as_text(&part)
73            && !self.forward_attaches()
74            && !self.config.mail.mime_forward_rest.unwrap_or(true)
75        {
76            self.error(format!(
77                "{} does not read as text, and $mime_forward_rest is off",
78                part.mimetype
79            ));
80            return None;
81        }
82        self.continue_setup(ComposeKind::Forward, Some(base), Some(index))
83    }
84
85    /// Whether a forward can quote this part: text, or a type an
86    /// auto_view filter turns into text.
87    fn part_reads_as_text(&self, part: &message::Part) -> bool {
88        part.is_text || self.display.filters.contains_key(&part.mimetype)
89    }
90
91    fn continue_setup(
92        &mut self,
93        kind: ComposeKind,
94        base: Option<ComposeBase>,
95        part: Option<usize>,
96    ) -> Option<Ask> {
97        // mutt's $autoedit (with edit_headers): no prompts, no
98        // questions: the defaults land in the draft and the editor
99        // opens; everything stays editable there and in the menu.
100        if self.config.mail.autoedit && self.edit_headers() {
101            let to = match (&kind, &base) {
102                (ComposeKind::Reply | ComposeKind::GroupReply, Some(b)) => b.reply_to.clone(),
103                (ComposeKind::ListReply, Some(b)) => self.list_target(b).unwrap_or_default(),
104                _ => String::new(),
105            };
106            let subject = match (&kind, &base) {
107                (
108                    ComposeKind::Reply | ComposeKind::GroupReply | ComposeKind::ListReply,
109                    Some(b),
110                ) => self.reply_subject(&b.subject),
111                (ComposeKind::Forward, Some(b)) => self.forward_subject(b),
112                _ => String::new(),
113            };
114            self.setup = Some(ComposeSetup {
115                kind,
116                base,
117                to: Some(to),
118                cc: None,
119                bcc: None,
120                subject_prefill: None,
121                subject: None,
122                fwd_attach: None,
123                part,
124            });
125            return self.finish_compose_setup(&subject, true);
126        }
127        let ask_reply_to = matches!(kind, ComposeKind::Reply | ComposeKind::GroupReply)
128            && base.as_ref().is_some_and(|b| b.has_reply_to);
129        self.setup = Some(ComposeSetup {
130            kind,
131            base,
132            to: None,
133            cc: None,
134            bcc: None,
135            subject_prefill: None,
136            subject: None,
137            fwd_attach: None,
138            part,
139        });
140        if ask_reply_to {
141            // mutt's $reply_to = ask-yes.
142            let addr = self
143                .setup
144                .as_ref()
145                .and_then(|s| s.base.as_ref())
146                .map(|b| b.reply_to.clone())
147                .unwrap_or_default();
148            return Some(Ask::Key {
149                label: format!("Reply to {addr}? (y/n): "),
150                what: AskKind::ReplyTo,
151            });
152        }
153        self.ask_to(true)
154    }
155
156    /// Who the draft goes to, unless $fast_reply says the prefill
157    /// will do.
158    fn ask_to(&mut self, use_reply_to: bool) -> Option<Ask> {
159        let setup = self.setup.as_ref()?;
160        let to_prefill = match setup.kind {
161            ComposeKind::Reply | ComposeKind::GroupReply => setup
162                .base
163                .as_ref()
164                .map(|b| {
165                    if use_reply_to {
166                        b.reply_to.clone()
167                    } else {
168                        b.from_hdr.clone()
169                    }
170                })
171                .unwrap_or_default(),
172            ComposeKind::ListReply => setup
173                .base
174                .as_ref()
175                .and_then(|b| self.list_target(b))
176                .unwrap_or_default(),
177            ComposeKind::New | ComposeKind::Forward => String::new(),
178        };
179        // mutt's $fast_reply: replies take the prefills without the
180        // To and Subject prompts (forwards still need a recipient).
181        if self.config.mail.fast_reply
182            && matches!(
183                setup.kind,
184                ComposeKind::Reply | ComposeKind::GroupReply | ComposeKind::ListReply
185            )
186            && setup.base.is_some()
187        {
188            let subject = setup
189                .base
190                .as_ref()
191                .map(|b| self.reply_subject(&b.subject))
192                .unwrap_or_default();
193            if let Some(setup) = &mut self.setup {
194                setup.to = Some(to_prefill);
195            }
196            return self.subject_submitted(&subject);
197        }
198        Some(Ask::Line {
199            label: "To: ".into(),
200            prefill: to_prefill,
201            wants: Wants::Address,
202            what: AskKind::ComposeTo,
203        })
204    }
205
206    fn setup_to_submitted(&mut self, input: &str) -> Option<Ask> {
207        let to = alias::expand(input, &alias::load(self.config.mail.alias_file.as_deref()));
208        self.setup.as_mut()?.to = Some(to);
209        let setup = self.setup.as_ref()?;
210        let subject_prefill = match (&setup.kind, &setup.base) {
211            (ComposeKind::Reply | ComposeKind::GroupReply | ComposeKind::ListReply, Some(b)) => {
212                self.reply_subject(&b.subject)
213            }
214            (ComposeKind::Forward, Some(b)) => self.forward_subject(b),
215            _ => String::new(),
216        };
217        // What the Subject prompt will offer, parked while the
218        // copies are asked about; $fast_reply skips the prompt when
219        // it gets there.
220        self.setup.as_mut()?.subject_prefill = Some(subject_prefill);
221        self.ask_cc_or_on()
222    }
223
224    /// mutt's $askcc: the copies, prefilled with whatever a group
225    /// reply worked out. Straight on when it is off.
226    fn ask_cc_or_on(&mut self) -> Option<Ask> {
227        if !self.config.mail.ask_cc {
228            return self.ask_bcc_or_on();
229        }
230        let prefill = self.group_cc().unwrap_or_default();
231        Some(Ask::Line {
232            label: "Cc: ".into(),
233            prefill,
234            wants: Wants::Address,
235            what: AskKind::ComposeCc,
236        })
237    }
238
239    /// mutt's $askbcc, which nothing prefills.
240    fn ask_bcc_or_on(&mut self) -> Option<Ask> {
241        if !self.config.mail.ask_bcc {
242            return self.ask_subject();
243        }
244        Some(Ask::Line {
245            label: "Bcc: ".into(),
246            prefill: String::new(),
247            wants: Wants::Address,
248            what: AskKind::ComposeBcc,
249        })
250    }
251
252    /// The Subject prompt, or straight past it when $fast_reply has
253    /// already filled it in.
254    fn ask_subject(&mut self) -> Option<Ask> {
255        let prefill = self.setup.as_mut()?.subject_prefill.take()?;
256        if self.config.mail.fast_reply && !prefill.is_empty() {
257            return self.subject_submitted(&prefill);
258        }
259        Some(Ask::Line {
260            label: "Subject: ".into(),
261            prefill,
262            wants: Wants::Other,
263            what: AskKind::ComposeSubject,
264        })
265    }
266
267    pub(crate) fn answer_cc(&mut self, input: &str) -> Option<Ask> {
268        let cc = alias::expand(input, &alias::load(self.config.mail.alias_file.as_deref()));
269        self.setup.as_mut()?.cc = Some(cc);
270        self.ask_bcc_or_on()
271    }
272
273    pub(crate) fn answer_bcc(&mut self, input: &str) -> Option<Ask> {
274        let bcc = alias::expand(input, &alias::load(self.config.mail.alias_file.as_deref()));
275        self.setup.as_mut()?.bcc = Some(bcc);
276        self.ask_subject()
277    }
278
279    /// mutt's group reply: everyone else on the original, minus who
280    /// is already in To and (unless $metoo) me. None when there is
281    /// nobody left, when this is not a group reply, or when the
282    /// sender named a Mail-Followup-To, which replaces To and leaves
283    /// the copies alone.
284    fn group_cc(&self) -> Option<String> {
285        let setup = self.setup.as_ref()?;
286        if setup.kind != ComposeKind::GroupReply {
287            return None;
288        }
289        let base = setup.base.as_ref()?;
290        if !base.followup_to.trim().is_empty() {
291            return None;
292        }
293        let joined = compose::group_recipients(
294            &base.orig_to,
295            &base.orig_cc,
296            setup.to.as_deref().unwrap_or_default(),
297            self.me(),
298            self.config.mail.metoo,
299        );
300        (!joined.is_empty()).then_some(joined)
301    }
302
303    /// mutt's $indent_string: what each quoted line starts with.
304    fn indent_string(&self) -> &str {
305        self.config
306            .mail
307            .indent_string
308            .as_deref()
309            .unwrap_or(compose::DEFAULT_INDENT)
310    }
311
312    /// mutt's $signature: the text this draft ends with, read (or run)
313    /// afresh for every draft, so a generated one can say something
314    /// new each time.
315    fn signature(&self) -> Option<String> {
316        compose::signature_text(self.config.mail.signature.as_deref()?)
317    }
318
319    /// mutt's $sig_dashes: on unless turned off, as in mutt.
320    fn sig_dashes(&self) -> bool {
321        self.config.mail.sig_dashes.unwrap_or(true)
322    }
323
324    /// After the Subject prompt: mutt's $abort_nosubject on an empty
325    /// subject, then on replies mutt's $include (ask-yes).
326    fn subject_submitted(&mut self, input: &str) -> Option<Ask> {
327        if input.trim().is_empty() {
328            // mutt's quadoption: the ask forms differ only in what
329            // Enter takes, and the other two answer it themselves.
330            match self
331                .config
332                .mail
333                .abort_nosubject
334                .as_deref()
335                .unwrap_or("ask-yes")
336            {
337                "no" => return self.subject_ready(String::new()),
338                "yes" => {
339                    self.cancel_setup();
340                    self.error("aborted (no subject)");
341                    return None;
342                }
343                quad => {
344                    return Some(Ask::Key {
345                        label: "No subject, abort? (y/n): ".into(),
346                        what: AskKind::NoSubject {
347                            default_yes: quad != "ask-no",
348                        },
349                    });
350                }
351            }
352        }
353        self.subject_ready(input.to_string())
354    }
355
356    fn subject_ready(&mut self, subject: String) -> Option<Ask> {
357        let is_reply = self.setup.as_ref().is_some_and(|s| {
358            matches!(
359                s.kind,
360                ComposeKind::Reply | ComposeKind::GroupReply | ComposeKind::ListReply
361            ) && s.base.is_some()
362        });
363        let ask_fwd = self.config.mail.forward.as_deref() == Some("ask")
364            && self
365                .setup
366                .as_ref()
367                .is_some_and(|s| s.kind == ComposeKind::Forward && s.base.is_some());
368        if is_reply {
369            // mutt's $include: "yes" and "no" decide it, the two
370            // ask forms ask, and Enter takes the one they name.
371            match self.config.mail.include.as_deref().unwrap_or("ask-yes") {
372                "yes" => return self.finish_compose_setup(&subject, true),
373                "no" => return self.finish_compose_setup(&subject, false),
374                include => {
375                    if let Some(setup) = &mut self.setup {
376                        setup.subject = Some(subject);
377                    }
378                    return Some(Ask::Key {
379                        label: "Include message in reply? (y/n): ".into(),
380                        what: AskKind::IncludeReply {
381                            default_yes: include != "ask-no",
382                        },
383                    });
384                }
385            }
386        }
387        if ask_fwd {
388            // mime_forward = "ask": whole original vs inline quote.
389            if let Some(setup) = &mut self.setup {
390                setup.subject = Some(subject);
391            }
392            return Some(Ask::Key {
393                label: "Forward as attachment? (y/n): ".into(),
394                what: AskKind::ForwardAttach,
395            });
396        }
397        self.finish_compose_setup(&subject, true)
398    }
399
400    /// The draft is built and on its way to the editor, or, for a
401    /// forward, possibly past it ($forward_edit), which may take one
402    /// more question.
403    fn finish_compose_setup(&mut self, subject: &str, include: bool) -> Option<Ask> {
404        let setup = self.setup.take()?;
405        // mutt's reply-hook: in force while this reply's draft is
406        // built, so `set from`, edit_headers and my_hdr all see it.
407        let reply_hooks = self.apply_reply_hooks(setup.base.as_ref(), setup.kind);
408        let ask = self.finish_compose_draft(setup, subject, include);
409        self.restore_after_reply_hooks(reply_hooks);
410        ask
411    }
412
413    fn finish_compose_draft(
414        &mut self,
415        setup: ComposeSetup,
416        subject: &str,
417        include: bool,
418    ) -> Option<Ask> {
419        let asked_cc = setup.cc.clone().filter(|cc| !cc.trim().is_empty());
420        let bcc = setup.bcc.clone().filter(|bcc| !bcc.trim().is_empty());
421        let mut to = setup.to.unwrap_or_default();
422        let mut cc = None;
423        let mut in_reply_to = None;
424        let mut references = None;
425        let mut body = String::new();
426        let mut attach = None;
427        let mut part_line = None;
428        if let Some(b) = &setup.base {
429            match setup.kind {
430                ComposeKind::Reply | ComposeKind::GroupReply | ComposeKind::ListReply => {
431                    if include {
432                        let orig = message::body_text(&b.path).unwrap_or_default();
433                        let quoted = self.quoted_of(b);
434                        let attribution = compose::attribution(
435                            self.config
436                                .mail
437                                .attribution
438                                .as_deref()
439                                .unwrap_or(compose::DEFAULT_ATTRIBUTION),
440                            &quoted,
441                        );
442                        body = compose::quote(&attribution, self.indent_string(), &orig);
443                    }
444                    in_reply_to = b.msg_id.clone();
445                    let mut refs = b.references.clone();
446                    if let Some(id) = &b.msg_id
447                        && !refs.contains(id)
448                    {
449                        refs.push(id.clone());
450                    }
451                    if !refs.is_empty() {
452                        references = Some(refs.join(" "));
453                    }
454                    if setup.kind == ComposeKind::GroupReply {
455                        // mutt honors a sender's Mail-Followup-To: it
456                        // is exactly the recipient set they asked for,
457                        // so it replaces To and leaves Cc alone.
458                        if !b.followup_to.trim().is_empty() {
459                            to = b.followup_to.trim().to_string();
460                        } else {
461                            // Everyone else on the original, minus the
462                            // recipient already in To and (mutt's
463                            // $metoo off) my own addresses: replying to
464                            // all should not mail me a copy.
465                            let joined = compose::group_recipients(
466                                &b.orig_to,
467                                &b.orig_cc,
468                                &to,
469                                self.me(),
470                                self.config.mail.metoo,
471                            );
472                            if !joined.is_empty() {
473                                cc = Some(joined);
474                            }
475                        }
476                    }
477                }
478                ComposeKind::Forward if setup.part.is_some() => {
479                    let whole = setup.fwd_attach.unwrap_or_else(|| self.forward_attaches());
480                    match self.forward_part(b, setup.part.unwrap_or_default(), whole) {
481                        Ok((text, line)) => {
482                            body = text;
483                            part_line = line;
484                        }
485                        Err(err) => {
486                            self.error(format!("cannot forward the part: {err:#}"));
487                            return None;
488                        }
489                    }
490                }
491                ComposeKind::Forward
492                    if setup.fwd_attach.unwrap_or_else(|| self.forward_attaches()) =>
493                {
494                    // The original goes along whole; nothing to quote.
495                    attach = Some(b.path.clone());
496                }
497                ComposeKind::Forward => {
498                    let orig = message::body_text(&b.path).unwrap_or_default();
499                    // mutt's $forward_quote: the original comes in
500                    // quoted, so a reply to the forward reads right.
501                    let indent = self.config.mail.forward_quote.then(|| self.indent_string());
502                    body =
503                        compose::forward_body(&b.from_display, b.date, &b.subject, &orig, indent);
504                }
505                ComposeKind::New => {}
506            }
507        }
508        // mutt's $signature closes every draft it starts, quoted
509        // original or not; $sig_on_top puts it above the quote.
510        if let Some(sig) = self.signature() {
511            let on_top = self.config.mail.sig_on_top.unwrap_or(false);
512            body = compose::with_signature_at(&body, &sig, self.sig_dashes(), on_top);
513        }
514        // An answered Cc ($askcc) is what the user said, over
515        // whatever the group reply worked out.
516        let cc = asked_cc.or(cc);
517        let from = self.compose_from(setup.base.as_ref(), &to);
518        let followup =
519            self.followup_header(&to, cc.as_deref(), from.as_deref().unwrap_or_default());
520        let text = compose::draft_text(
521            &compose::DraftHeaders {
522                from,
523                to,
524                cc,
525                subject: subject.to_string(),
526                in_reply_to,
527                references,
528            },
529            &body,
530        );
531        // DraftHeaders has no Mail-Followup-To or Bcc slot; both go
532        // ahead of the blank line, where edit_headers shows them like
533        // any other header.
534        let mut extra: Vec<String> = Vec::new();
535        if let Some(value) = followup {
536            extra.push(format!("Mail-Followup-To: {value}"));
537        }
538        if let Some(value) = bcc {
539            extra.push(format!("Bcc: {value}"));
540        }
541        extra.extend(part_line);
542        let text = match extra.is_empty() {
543            true => text,
544            false => match text.split_once("\n\n") {
545                Some((head, rest)) => format!("{head}\n{}\n\n{rest}", extra.join("\n")),
546                None => text,
547            },
548        };
549        match self.stage_draft(&text) {
550            Ok((path, hidden_head)) => {
551                // What the editor is being handed, for mutt's
552                // $abort_unmodified when it hands it straight back.
553                let staged = std::fs::read_to_string(&path).unwrap_or_default();
554                self.staged = Some((path.clone(), staged));
555                let security = self.security_for(&setup.kind, setup.base.as_ref());
556                let draft = Compose {
557                    path,
558                    recall_source: None,
559                    security,
560                    attach,
561                    hidden_head,
562                    fcc: None,
563                };
564                // mutt's $forward_edit, except that $autoedit with
565                // edit_headers always edits, as in mutt.
566                let forward_edit = match setup.kind {
567                    ComposeKind::Forward if !(self.config.mail.autoedit && self.edit_headers()) => {
568                        self.config.mail.forward_edit.as_deref().unwrap_or("yes")
569                    }
570                    _ => "yes",
571                };
572                match forward_edit {
573                    "no" => self.skip_editor(draft),
574                    ask @ ("ask-yes" | "ask-no") => {
575                        self.parked_forward = Some(draft);
576                        return Some(Ask::Key {
577                            label: "Edit forwarded message? (y/n): ".into(),
578                            what: AskKind::ForwardEdit {
579                                default_yes: ask == "ask-yes",
580                            },
581                        });
582                    }
583                    _ => self.requests.push(Request::Editor(draft)),
584                }
585            }
586            Err(err) => self.error(format!("cannot write draft: {err:#}")),
587        }
588        None
589    }
590
591    /// Straight to the compose menu with the draft as it was built,
592    /// the way the editor hands one back.
593    fn skip_editor(&mut self, draft: Compose) {
594        // Nobody edited it, and that is not $abort_unmodified's case.
595        self.staged = None;
596        self.set_draft(draft);
597        self.requests.push(Request::ShowDraft);
598    }
599
600    /// $forward_edit asked: the parked forward goes to the editor or
601    /// straight to the compose menu. Anything but y or n (or Enter)
602    /// cancels the forward.
603    pub(crate) fn answer_forward_edit(&mut self, edit: Option<bool>) -> Option<Ask> {
604        let draft = self.parked_forward.take()?;
605        match edit {
606            Some(true) => self.requests.push(Request::Editor(draft)),
607            Some(false) => self.skip_editor(draft),
608            None => {
609                let _ = std::fs::remove_file(&draft.path);
610                self.staged = None;
611                self.note("forward cancelled");
612            }
613        }
614        None
615    }
616
617    /// The Reply-To question is answered: on to the recipient.
618    pub(crate) fn answer_reply_to(&mut self, use_reply_to: bool) -> Option<Ask> {
619        self.ask_to(use_reply_to)
620    }
621
622    pub(crate) fn answer_to(&mut self, input: &str) -> Option<Ask> {
623        self.setup_to_submitted(input)
624    }
625
626    pub(crate) fn answer_subject(&mut self, input: &str) -> Option<Ask> {
627        self.subject_submitted(input)
628    }
629
630    /// mutt's $abort_nosubject answered "no, send it anyway".
631    pub(crate) fn answer_subject_kept(&mut self) -> Option<Ask> {
632        self.subject_ready(String::new())
633    }
634
635    pub(crate) fn answer_include(&mut self, include: bool) -> Option<Ask> {
636        let subject = self.parked_subject();
637        self.finish_compose_setup(&subject, include)
638    }
639
640    pub(crate) fn answer_forward_attach(&mut self, attach: bool) -> Option<Ask> {
641        let subject = self.parked_subject();
642        if let Some(setup) = &mut self.setup {
643            setup.fwd_attach = Some(attach);
644        }
645        self.finish_compose_setup(&subject, true)
646    }
647
648    /// A forwarded part: the body that quotes it, and the Attach line
649    /// that carries it when it goes as a file (`whole`, or a part that
650    /// does not read as text). The file is the part decoded into the
651    /// temp directory, unlinked once the message is sent.
652    fn forward_part(
653        &self,
654        b: &ComposeBase,
655        index: usize,
656        whole: bool,
657    ) -> Result<(String, Option<String>)> {
658        let part = message::parts(&b.path)?
659            .into_iter()
660            .nth(index)
661            .ok_or_else(|| anyhow::anyhow!("no part {}", index + 1))?;
662        let indent = self.config.mail.forward_quote.then(|| self.indent_string());
663        let quote =
664            |text: &str| compose::forward_body(&b.from_display, b.date, &b.subject, text, indent);
665        if !whole && self.part_reads_as_text(&part) {
666            let text = match self.display.filters.get(&part.mimetype) {
667                Some(command) => message::filter_part(&b.path, index, command)?,
668                None => {
669                    let text = message::part_text(&b.path, index)?;
670                    match part.mimetype == "text/html" && self.display.html_to_text {
671                        true => rmut_core::html::to_text(&text),
672                        false => text,
673                    }
674                }
675            };
676            return Ok((quote(&text), None));
677        }
678        let bytes = message::part_bytes(&b.path, index)?;
679        let dir = std::env::temp_dir().join(format!("rmut-{}", std::process::id()));
680        std::fs::create_dir_all(&dir)?;
681        // The part's own name, its basename only, as mutt's sanitizer
682        // leaves it; a second forward of the same name gets a prefix
683        // on disk and goes out under the name it came with.
684        let name = part
685            .filename
686            .as_deref()
687            .and_then(|n| Path::new(n).file_name())
688            .map(|n| n.to_string_lossy().into_owned())
689            .unwrap_or_else(|| format!("part-{}", index + 1));
690        let mut path = dir.join(&name);
691        let mut n = 1;
692        while path.exists() {
693            n += 1;
694            path = dir.join(format!("{n}-{name}"));
695        }
696        std::fs::write(&path, bytes)?;
697        let mut file = compose::Attachment::of(path);
698        file.mime = Some(part.mimetype.clone());
699        file.name = (n > 1).then_some(name);
700        file.unlink = true;
701        Ok((quote(""), Some(compose::attach_line(&file))))
702    }
703
704    /// The subject parked while a question was up.
705    fn parked_subject(&mut self) -> String {
706        self.setup
707            .as_mut()
708            .and_then(|s| s.subject.take())
709            .unwrap_or_default()
710    }
711
712    /// The message a reply or a forward is about, for the format
713    /// strings that describe it.
714    fn quoted_of<'a>(&self, base: &'a ComposeBase) -> compose::Quoted<'a> {
715        compose::Quoted {
716            from: &base.from_hdr,
717            subject: &base.subject,
718            message_id: base.msg_id.as_deref(),
719            date: base.date,
720        }
721    }
722
723    /// mutt's $forward_format over the message being forwarded.
724    fn forward_subject(&self, base: &ComposeBase) -> String {
725        compose::forward_subject(
726            self.config
727                .mail
728                .forward_format
729                .as_deref()
730                .unwrap_or(compose::DEFAULT_FORWARD_FORMAT),
731            &self.quoted_of(base),
732        )
733    }
734
735    /// Give up on the draft that was being set up.
736    pub fn cancel_setup(&mut self) {
737        self.setup = None;
738    }
739}