1use 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 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 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 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 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 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 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 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 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 self.setup.as_mut()?.subject_prefill = Some(subject_prefill);
221 self.ask_cc_or_on()
222 }
223
224 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 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 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 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 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 fn signature(&self) -> Option<String> {
316 compose::signature_text(self.config.mail.signature.as_deref()?)
317 }
318
319 fn sig_dashes(&self) -> bool {
321 self.config.mail.sig_dashes.unwrap_or(true)
322 }
323
324 fn subject_submitted(&mut self, input: &str) -> Option<Ask> {
327 if input.trim().is_empty() {
328 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 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 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 fn finish_compose_setup(&mut self, subject: &str, include: bool) -> Option<Ask> {
404 let setup = self.setup.take()?;
405 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 "ed,
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 if !b.followup_to.trim().is_empty() {
459 to = b.followup_to.trim().to_string();
460 } else {
461 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 attach = Some(b.path.clone());
496 }
497 ComposeKind::Forward => {
498 let orig = message::body_text(&b.path).unwrap_or_default();
499 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 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 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 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 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 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 fn skip_editor(&mut self, draft: Compose) {
594 self.staged = None;
596 self.set_draft(draft);
597 self.requests.push(Request::ShowDraft);
598 }
599
600 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 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 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 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 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 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 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 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 pub fn cancel_setup(&mut self) {
737 self.setup = None;
738 }
739}