1use super::*;
2
3impl Workspace {
4 pub fn show_draft(&self, case_ref: &str, draft_name: &str) -> Result<Value> {
5 self.require_workspace()?;
6 validate_file_name("draft_name", draft_name)?;
7 let (case_uid, case_path) = self.resolve_active_case(case_ref)?;
8 let draft_path = case_path.join("drafts").join(draft_name);
9 if !draft_path.is_file() {
10 return Err(AppError::new(
11 "draft_not_found",
12 format!("draft not found: {draft_name}"),
13 ));
14 }
15 let text = read_to_string(&draft_path, "read draft")?;
16 let (fm, body) = read_doc::<DraftFrontmatter>(&text).map_err(|e| {
17 AppError::new("draft_invalid", format!("invalid draft frontmatter: {e}"))
18 })?;
19 let queued = crate::push_queue::find_outbound_for_draft(&self.root, &case_uid, draft_name)?;
20 let queued_action = queued
21 .as_ref()
22 .and_then(|item| item.outbound())
23 .map(|outbound| outbound.action.as_str());
24 let push_id = queued.as_ref().map(|item| item.push_id.as_str());
25 let attachments = fm
26 .attachments
27 .iter()
28 .map(|attachment| draft_attachment_show_value(&case_path, attachment))
29 .collect::<Vec<_>>();
30 Ok(json!({
31 "code": "draft_shown",
32 "case_uid": case_uid,
33 "draft_name": draft_name,
34 "draft_path": rel_path(&self.root, &draft_path),
35 "subject": fm.subject,
36 "to": fm.to,
37 "cc": fm.cc,
38 "attachments": attachments,
39 "body": body,
40 "queued_action": queued_action,
41 "push_id": push_id,
42 "remote_changed": false,
43 }))
44 }
45
46 pub fn validate_draft(&self, case_ref: &str, draft_name: &str) -> Result<Value> {
47 self.require_workspace()?;
48 let (case_uid, case_path) = self.resolve_active_case(case_ref)?;
49 let validation = self.validate_draft_inner(&case_uid, draft_name, &case_path)?;
50 let now = now_rfc3339();
51 write_draft_validation_state(&case_path, draft_name, &validation, &now)?;
52 Ok(json!({
53 "code": "draft_valid",
54 "case_uid": case_uid,
55 "draft_name": draft_name,
56 "draft_hash": validation.draft_hash,
57 "last_validated_rfc3339": now
58 }))
59 }
60
61 pub fn attach_file_to_draft(
62 &self,
63 case_ref: &str,
64 draft_name: &str,
65 source_path: &str,
66 ) -> Result<Value> {
67 self.require_workspace()?;
68 validate_file_name("draft_name", draft_name)?;
69 let (case_uid, case_path) = self.resolve_active_case(case_ref)?;
70 let draft_path = case_path.join("drafts").join(draft_name);
71 if !draft_path.is_file() {
72 return Err(AppError::new(
73 "draft_not_found",
74 format!("draft not found: {draft_name}"),
75 ));
76 }
77
78 let source = resolve_cli_path(source_path)?;
79 if !source.is_file() {
80 return Err(AppError::new(
81 "draft_invalid",
82 format!("draft attachment source is not a file: {source_path}"),
83 ));
84 }
85 let source_abs =
86 fs::canonicalize(&source).map_err(|e| AppError::io("canonicalize attachment", &e))?;
87 let case_abs =
88 fs::canonicalize(&case_path).map_err(|e| AppError::io("canonicalize case", &e))?;
89 let text = read_to_string(&draft_path, "read draft")?;
90 let (mut fm, body) = read_doc::<DraftFrontmatter>(&text)?;
91
92 let (attachment, file_path, copied) = if source_abs.starts_with(&case_abs) {
93 let relative = source_abs
94 .strip_prefix(&case_abs)
95 .map_err(|e| AppError::new("draft_invalid", e.to_string()))?;
96 (
97 path_to_string(relative),
98 rel_path(&self.root, &source_abs),
99 false,
100 )
101 } else {
102 let files_dir = case_path.join("files");
103 create_dir_all(&files_dir)?;
104 let filename = source_abs
105 .file_name()
106 .and_then(|name| name.to_str())
107 .unwrap_or("attachment");
108 let saved_filename = safe_attachment_filename(filename, "attachment");
109 let candidate_attachment = format!("files/{saved_filename}");
110 let already_present = fm
111 .attachments
112 .iter()
113 .any(|item| item == &candidate_attachment);
114 let dest = if already_present && files_dir.join(&saved_filename).is_file() {
115 files_dir.join(&saved_filename)
116 } else {
117 let dest = unique_dest_path(&files_dir, &saved_filename);
118 fs::copy(&source_abs, &dest)
119 .map_err(|e| AppError::io("copy draft attachment", &e))?;
120 dest
121 };
122 (
123 format!("files/{}", path_file_name(&dest)),
124 rel_path(&self.root, &dest),
125 !already_present,
126 )
127 };
128
129 let already_present = fm.attachments.iter().any(|item| item == &attachment);
130 if !already_present {
131 fm.attachments.push(attachment.clone());
132 write_string(&draft_path, &render_frontmatter(&fm, &body)?)?;
133 }
134 let size_bytes = fs::metadata(self.root.join(&file_path))
135 .or_else(|_| fs::metadata(&source_abs))
136 .map_err(|e| AppError::io("stat draft attachment", &e))?
137 .len();
138 Ok(json!({
139 "code": "draft_attachment_added",
140 "case_uid": case_uid,
141 "draft_name": draft_name,
142 "draft_path": rel_path(&self.root, &draft_path),
143 "source_path": path_to_string(&source_abs),
144 "attachment": attachment,
145 "file_path": file_path,
146 "copied": copied,
147 "already_present": already_present,
148 "size_bytes": size_bytes,
149 "requires_validate": true,
150 }))
151 }
152
153 pub fn remove_draft(
154 &self,
155 case_ref: &str,
156 draft_name: &str,
157 reason: Option<&str>,
158 ) -> Result<Value> {
159 self.require_workspace()?;
160 let reason = self.checked_reason(reason)?;
161 validate_file_name("draft_name", draft_name)?;
162 let (case_uid, case_path) = self.resolve_active_case(case_ref)?;
163 let draft_path = case_path.join("drafts").join(draft_name);
164 let removed_push =
165 crate::push_queue::remove_outbound_for_draft(&self.root, &case_uid, draft_name)?;
166 let mut draft_state = read_draft_state(&case_path)?;
167 let state_removed = draft_state.drafts.remove(draft_name).is_some();
168 let draft_deleted = if draft_path.is_file() {
169 remove_file(&draft_path)?;
170 true
171 } else {
172 false
173 };
174 if !draft_deleted && !state_removed && removed_push.is_empty() {
175 return Err(AppError::new(
176 "draft_not_found",
177 format!("draft not found: {draft_name}"),
178 ));
179 }
180 write_draft_state(&case_path, &draft_state)?;
181 let push_ids = removed_push
182 .iter()
183 .map(|item| item.push_id.clone())
184 .collect::<Vec<_>>();
185 let actions = removed_push
186 .iter()
187 .map(|item| item.action.as_str())
188 .collect::<Vec<_>>();
189 self.append_audit_event(
190 "draft_removed",
191 vec![audit_target("case", &case_uid)],
192 reason,
193 json!({
194 "case_uid": case_uid,
195 "draft_name": draft_name,
196 "draft_path": rel_path(&self.root, &draft_path),
197 "push_ids": push_ids.clone(),
198 "actions": actions.clone(),
199 "draft_deleted": draft_deleted,
200 "state_removed": state_removed,
201 "mail_sent": false,
202 }),
203 )?;
204 Ok(json!({
205 "code": "draft_removed",
206 "case_uid": case_uid,
207 "draft_name": draft_name,
208 "draft_path": rel_path(&self.root, &draft_path),
209 "draft_deleted": draft_deleted,
210 "state_removed": state_removed,
211 "queued_removed": !push_ids.is_empty(),
212 "removed_push_count": push_ids.len(),
213 "push_ids": push_ids,
214 "actions": actions,
215 "mail_sent": false
216 }))
217 }
218
219 pub(crate) fn change_draft(
220 &self,
221 case_ref: &str,
222 draft_name: &str,
223 change: DraftChange<'_>,
224 ) -> Result<Value> {
225 self.require_workspace()?;
226 validate_file_name("draft_name", draft_name)?;
227 let body_override = draft_body_override(change.body, change.body_file)?;
228 let (case_uid, case_path) = self.resolve_active_case(case_ref)?;
229 let draft_path = case_path.join("drafts").join(draft_name);
230 if !draft_path.is_file() {
231 return Err(AppError::new(
232 "draft_not_found",
233 format!("draft not found: {draft_name}"),
234 ));
235 }
236 let text = read_to_string(&draft_path, "read draft")?;
237 let (mut fm, mut current_body) = read_doc::<DraftFrontmatter>(&text).map_err(|e| {
238 AppError::new("draft_invalid", format!("invalid draft frontmatter: {e}"))
239 })?;
240 let mut changed_fields = Vec::new();
241 if let Some(subject) = change.subject {
242 let replacement = Some(subject.to_string());
243 if fm.subject != replacement {
244 fm.subject = replacement;
245 changed_fields.push("subject".to_string());
246 }
247 }
248 if !change.to.is_empty() && fm.to != change.to {
249 fm.to = change.to.to_vec();
250 changed_fields.push("to".to_string());
251 }
252 if change.clear_cc {
253 if !fm.cc.is_empty() {
254 fm.cc.clear();
255 changed_fields.push("cc".to_string());
256 }
257 } else if !change.cc.is_empty() && fm.cc != change.cc {
258 fm.cc = change.cc.to_vec();
259 changed_fields.push("cc".to_string());
260 }
261 if let Some(new_body) = body_override {
262 if current_body != new_body {
263 current_body = new_body;
264 changed_fields.push("body".to_string());
265 }
266 }
267 let rendered = render_frontmatter(&fm, ¤t_body)?;
268 let validation = self.validate_draft_bytes_inner(
269 &case_uid,
270 draft_name,
271 &case_path,
272 rendered.as_bytes(),
273 )?;
274 write_string(&draft_path, &rendered)?;
275 let now = now_rfc3339();
276 write_draft_validation_state(&case_path, draft_name, &validation, &now)?;
277 self.append_audit_event(
278 "draft_changed",
279 vec![audit_target("case", &case_uid)],
280 None,
281 json!({
282 "case_uid": case_uid,
283 "draft_name": draft_name,
284 "draft_path": rel_path(&self.root, &draft_path),
285 "changed_fields": changed_fields.clone(),
286 "draft_hash": validation.draft_hash.as_str(),
287 }),
288 )?;
289 Ok(json!({
290 "code": "draft_changed",
291 "case_uid": case_uid,
292 "draft_name": draft_name,
293 "draft_path": rel_path(&self.root, &draft_path),
294 "changed_fields": changed_fields,
295 "draft_hash": validation.draft_hash.as_str(),
296 "last_validated_rfc3339": now,
297 }))
298 }
299
300 pub fn save_draft(&self, case_ref: &str, draft_name: &str) -> Result<Value> {
301 self.queue_draft(case_ref, draft_name, OutboundAction::SaveDraft)
302 }
303
304 pub fn send_draft(&self, case_ref: &str, draft_name: &str) -> Result<Value> {
305 self.queue_draft(case_ref, draft_name, OutboundAction::Send)
306 }
307
308 fn queue_draft(
309 &self,
310 case_ref: &str,
311 draft_name: &str,
312 action: OutboundAction,
313 ) -> Result<Value> {
314 self.require_workspace()?;
315 validate_file_name("draft_name", draft_name)?;
316 let (case_uid, case_path) = self.resolve_active_case(case_ref)?;
317 let draft_path = case_path.join("drafts").join(draft_name);
318 if !draft_path.is_file() {
319 return Err(AppError::new(
320 "draft_not_found",
321 format!("draft not found: {draft_name}"),
322 ));
323 }
324 let validation = self.validate_draft_inner(&case_uid, draft_name, &case_path)?;
325 let now = now_rfc3339();
326 let transaction = self.begin_transaction(
327 "draft_queue",
328 vec![
329 rel_path(&self.root, &draft_path),
330 rel_path(&self.root, &case_drafts_json_path(&case_path)),
331 ".afmail/push".to_string(),
332 ],
333 )?;
334 write_draft_validation_state(&case_path, draft_name, &validation, &now)?;
335 let mut queued =
336 crate::push_queue::queue_outbound(&self.root, &case_uid, draft_name, action)?;
337 if let Some(object) = queued.as_object_mut() {
338 object.insert(
339 "draft_path".to_string(),
340 json!(rel_path(&self.root, &draft_path)),
341 );
342 object.insert("last_validated_rfc3339".to_string(), json!(now));
343 }
344 transaction.commit()?;
345 self.append_audit_event(
346 "draft_queued",
347 vec![audit_target("case", &case_uid)],
348 None,
349 json!({
350 "case_uid": case_uid,
351 "draft_name": draft_name,
352 "draft_path": rel_path(&self.root, &draft_path),
353 "action": action.as_str(),
354 "push_id": queued.get("push_id").and_then(Value::as_str),
355 }),
356 )?;
357 Ok(queued)
358 }
359
360 pub(crate) fn validate_draft_for_push(
361 &self,
362 case_uid: &str,
363 draft_name: &str,
364 case_path: &Path,
365 ) -> Result<()> {
366 self.validate_draft_inner(case_uid, draft_name, case_path)
367 .map(|_| ())
368 }
369
370 pub(crate) fn consume_outbound_draft_after_push(
371 &self,
372 item: &PushItem,
373 config: &MailConfig,
374 ) -> Result<()> {
375 let Some(outbound) = item.outbound() else {
376 return Ok(());
377 };
378 let (case_uid, case_path) = self.resolve_active_case(&outbound.case_uid)?;
379 let draft_path = case_path.join("drafts").join(&outbound.draft_name);
380 if outbound.action == OutboundAction::Send {
381 let prepared = crate::smtp_send::prepare_outbound(
382 &self.root,
383 &case_path,
384 &case_uid,
385 &outbound.draft_name,
386 config,
387 Some(&crate::smtp_send::message_id_for_push(&item.push_id)),
388 )?;
389 crate::smtp_send::mark_sent_and_append_case(
390 &self.root,
391 &case_path,
392 &case_uid,
393 &prepared.message_id,
394 &prepared.raw,
395 config,
396 )?;
397 }
398 let draft_deleted = if draft_path.is_file() {
399 remove_file(&draft_path)?;
400 true
401 } else {
402 false
403 };
404 let mut draft_state = read_draft_state(&case_path)?;
405 let state_removed = draft_state.drafts.remove(&outbound.draft_name).is_some();
406 write_draft_state(&case_path, &draft_state)?;
407 self.append_audit_event(
408 match outbound.action {
409 OutboundAction::SaveDraft => "draft_saved_remote",
410 OutboundAction::Send => "draft_sent",
411 },
412 vec![audit_target("case", &case_uid)],
413 None,
414 json!({
415 "case_uid": case_uid.as_str(),
416 "draft_name": outbound.draft_name.as_str(),
417 "draft_path": rel_path(&self.root, &draft_path),
418 "push_id": item.push_id.as_str(),
419 "action": outbound.action.as_str(),
420 "draft_deleted": draft_deleted,
421 "state_removed": state_removed,
422 }),
423 )
424 }
425
426 pub fn reply_to_message(
427 &self,
428 case_ref: &str,
429 message_id: &str,
430 reply_all: bool,
431 body: Option<&str>,
432 body_file: Option<&str>,
433 ) -> Result<Value> {
434 self.require_workspace()?;
435 let body_override = draft_body_override(body, body_file)?;
436 validate_id("message_id", message_id)?;
437 let (case_uid, case_path) = self.resolve_active_case(case_ref)?;
438 let messages = read_case_messages(&case_path, &case_uid)?;
439 if !messages.contains_message(message_id) {
440 return Err(AppError::new(
441 "invalid_request",
442 format!("message does not belong to case: {message_id}"),
443 ));
444 }
445 let message = self.read_message_by_id(message_id)?;
446 let original_subject = message.subject.as_deref().unwrap_or("");
447 let subject = if original_subject
448 .trim_start()
449 .to_lowercase()
450 .starts_with("re:")
451 {
452 original_subject.to_string()
453 } else {
454 format!("Re: {original_subject}")
455 };
456 let config = MailConfig::load(&self.root)?;
459 let own_email = config
460 .smtp
461 .from
462 .as_deref()
463 .or(config.imap.username.as_deref())
464 .map(email_address);
465 let mut seen: BTreeSet<String> = BTreeSet::new();
466 if let Some(own) = &own_email {
467 seen.insert(own.clone());
468 }
469 let mut to: Vec<String> = Vec::new();
470 let mut to_sources: Vec<&String> = if message.reply_to.is_empty() {
471 message.from.iter().collect()
472 } else {
473 message.reply_to.iter().collect()
474 };
475 if reply_all {
476 to_sources.extend(message.to.iter());
477 }
478 for addr in to_sources {
479 let key = email_address(addr);
480 if !key.is_empty() && seen.insert(key) {
481 to.push(addr.clone());
482 }
483 }
484 let mut cc: Vec<String> = Vec::new();
485 if reply_all {
486 for addr in &message.cc {
487 let key = email_address(addr);
488 if !key.is_empty() && seen.insert(key) {
489 cc.push(addr.clone());
490 }
491 }
492 }
493 let fm = DraftFrontmatter {
494 kind: Some("draft".to_string()),
495 case_uid: case_uid.to_string(),
496 send_intent: Some("reply".to_string()),
497 reply_to_message_id: Some(message_id.to_string()),
498 subject: Some(subject),
499 to,
500 cc,
501 attachments: Vec::new(),
502 };
503 let body = if let Some(body) = body_override {
504 body
505 } else {
506 let quoted = self.quoted_message_body(&message)?;
507 render_draft_reply_body(
508 &self.root,
509 config.template_language(),
510 message.from.as_deref(),
511 "ed,
512 )?
513 };
514 let draft_name = format!("reply-{message_id}.md");
515 let draft_path = case_path.join("drafts").join(&draft_name);
516 if draft_path.exists() {
517 return Err(AppError::new(
518 "draft_exists",
519 format!("reply draft already exists: {draft_name}"),
520 ));
521 }
522 create_dir_all(&case_path.join("drafts"))?;
523 write_string_new(&draft_path, &render_frontmatter(&fm, &body)?)?;
524 Ok(json!({
525 "code": "draft_created",
526 "case_uid": case_uid,
527 "message_id": message_id,
528 "draft_name": draft_name,
529 "draft_path": rel_path(&self.root, &draft_path)
530 }))
531 }
532
533 pub fn create_draft(
534 &self,
535 case_ref: &str,
536 to: &[String],
537 cc: &[String],
538 subject: &str,
539 body: Option<&str>,
540 body_file: Option<&str>,
541 ) -> Result<Value> {
542 self.require_workspace()?;
543 let body_override = draft_body_override(body, body_file)?;
544 let (case_uid, case_path) = self.resolve_active_case(case_ref)?;
545 if to.is_empty() {
546 return Err(AppError::new(
547 "invalid_request",
548 "draft requires at least one --to recipient",
549 ));
550 }
551 let fm = DraftFrontmatter {
552 kind: Some("draft".to_string()),
553 case_uid: case_uid.to_string(),
554 send_intent: Some("new".to_string()),
555 reply_to_message_id: None,
556 subject: Some(subject.to_string()),
557 to: to.to_vec(),
558 cc: cc.to_vec(),
559 attachments: Vec::new(),
560 };
561 let slug = {
562 let slug = crate::mail::slugify(subject);
563 if slug.is_empty() {
564 "message".to_string()
565 } else {
566 slug
567 }
568 };
569 let drafts_dir = case_path.join("drafts");
570 create_dir_all(&drafts_dir)?;
571 let mut draft_name = format!("new-{slug}.md");
572 let mut counter = 1;
573 while drafts_dir.join(&draft_name).exists() {
574 counter += 1;
575 draft_name = format!("new-{slug}-{counter}.md");
576 }
577 let draft_path = drafts_dir.join(&draft_name);
578 let language = self.template_language()?;
579 let body = match body_override {
580 Some(body) => body,
581 None => render_draft_new_body(&self.root, language)?,
582 };
583 write_string_new(&draft_path, &render_frontmatter(&fm, &body)?)?;
584 Ok(json!({
585 "code": "draft_created",
586 "case_uid": case_uid,
587 "draft_name": draft_name,
588 "draft_path": rel_path(&self.root, &draft_path)
589 }))
590 }
591
592 fn quoted_message_body(&self, message: &MessageFile) -> Result<String> {
593 let quoted = message
594 .body_text
595 .lines()
596 .map(|line| {
597 if line.is_empty() {
598 ">".to_string()
599 } else {
600 format!("> {line}")
601 }
602 })
603 .collect::<Vec<_>>()
604 .join("\n");
605 Ok(quoted)
606 }
607
608 pub fn fetch_message_attachment(
609 &self,
610 message_id: &str,
611 part_id: Option<&str>,
612 ) -> Result<Value> {
613 self.require_workspace()?;
614 validate_id("message_id", message_id)?;
615 let dest = self
616 .root
617 .join(format!(".afmail/messages/{message_id}.files"));
618 match part_id {
619 Some(part_id) => {
620 let saved = self.fetch_attachment_to(message_id, part_id, &dest)?;
621 self.refresh_read_views_after_message_change(message_id)?;
622 Ok(saved_attachment_value(
623 &self.root,
624 "attachment_saved",
625 message_id,
626 &saved,
627 ))
628 }
629 None => {
630 let message = self.read_message_by_id(message_id)?;
631 let mut items = Vec::new();
632 for attachment in &message.attachments {
633 let saved = self.fetch_attachment_to(message_id, &attachment.part_id, &dest)?;
634 items.push(saved_attachment_value(
635 &self.root,
636 "attachment_saved",
637 message_id,
638 &saved,
639 ));
640 }
641 self.refresh_read_views_after_message_change(message_id)?;
642 Ok(json!({
643 "code": "attachments_saved",
644 "message_id": message_id,
645 "count": items.len(),
646 "items": items,
647 }))
648 }
649 }
650 }
651
652 fn validate_draft_inner(
653 &self,
654 case_uid: &str,
655 draft_name: &str,
656 case_path: &Path,
657 ) -> Result<DraftValidation> {
658 validate_file_name("draft_name", draft_name)?;
659 let draft_path = case_path.join("drafts").join(draft_name);
660 let draft_bytes = fs::read(&draft_path).map_err(|e| AppError::io("read draft", &e))?;
661 self.validate_draft_bytes_inner(case_uid, draft_name, case_path, &draft_bytes)
662 }
663
664 fn validate_draft_bytes_inner(
665 &self,
666 case_uid: &str,
667 draft_name: &str,
668 case_path: &Path,
669 draft_bytes: &[u8],
670 ) -> Result<DraftValidation> {
671 validate_file_name("draft_name", draft_name)?;
672 let draft_hash = sha256_fingerprint(draft_bytes);
673 let draft = std::str::from_utf8(draft_bytes)
674 .map_err(|e| AppError::new("draft_invalid", format!("draft is not UTF-8: {e}")))?;
675 let (fm, _) = read_doc::<DraftFrontmatter>(draft).map_err(|e| {
676 AppError::new("draft_invalid", format!("invalid draft frontmatter: {e}"))
677 })?;
678 if fm.kind.as_deref() != Some("draft") {
679 return Err(AppError::new("draft_invalid", "draft kind must be draft"));
680 }
681 if fm.case_uid != case_uid {
682 return Err(AppError::new(
683 "draft_invalid",
684 "draft case_uid does not match case",
685 ));
686 }
687 if fm
688 .subject
689 .as_deref()
690 .map(|subject| subject.trim().is_empty())
691 .unwrap_or(true)
692 {
693 return Err(AppError::new("draft_invalid", "draft subject is required"));
694 }
695 if fm.to.is_empty() {
696 return Err(AppError::new("draft_invalid", "draft to is required"));
697 }
698 if let Some(reply_id) = fm.reply_to_message_id.as_ref() {
699 let messages = read_case_messages(case_path, case_uid)?;
700 if !messages.contains_message(reply_id) {
701 return Err(AppError::new(
702 "draft_invalid",
703 format!("reply_to_message_id does not belong to case: {reply_id}"),
704 ));
705 }
706 }
707 for attachment in &fm.attachments {
708 let attachment_path = draft_attachment_path(case_path, attachment)?;
709 if !attachment_path.is_file() {
710 return Err(AppError::new(
711 "draft_invalid",
712 format!("draft attachment does not exist: {attachment}"),
713 ));
714 }
715 }
716 Ok(DraftValidation { draft_hash })
717 }
718
719 fn fetch_attachment_to(
720 &self,
721 message_id: &str,
722 part_id: &str,
723 dest_dir: &Path,
724 ) -> Result<SavedAttachment> {
725 validate_id("message_id", message_id)?;
726 let mut message = self.read_message_by_id(message_id)?;
727 let Some(pos) = message
728 .attachments
729 .iter()
730 .position(|a| a.part_id == part_id)
731 else {
732 return Err(AppError::new(
733 "attachment_not_found",
734 format!("attachment not found: {message_id} part {part_id}"),
735 ));
736 };
737 let attachment = message.attachments[pos].clone();
738 create_dir_all(dest_dir)?;
739 if attachment.fetched {
740 if let Some(file_path) = attachment.file_path.as_deref() {
741 let existing = self.root.join(file_path);
742 if existing.is_file() {
743 let size_bytes = fs::metadata(&existing)
744 .map_err(|e| AppError::io("stat attachment", &e))?
745 .len();
746 return Ok(SavedAttachment {
747 part_id: attachment.part_id,
748 filename: attachment.filename,
749 saved_filename: path_file_name(&existing),
750 content_type: attachment.content_type,
751 path: existing,
752 size_bytes,
753 });
754 }
755 }
756 }
757 let saved_filename = safe_attachment_filename(&attachment.filename, part_id);
758 let dest = unique_dest_path(dest_dir, &saved_filename);
759 if let Some(source_path) = attachment.source_path.clone() {
760 fs::copy(self.root.join(source_path), &dest)
761 .map_err(|e| AppError::io("copy attachment", &e))?;
762 } else {
763 let eml_path = message
764 .eml_path
765 .clone()
766 .unwrap_or_else(|| format!(".afmail/messages/{message_id}.eml"));
767 let raw =
768 fs::read(self.root.join(eml_path)).map_err(|e| AppError::io("read eml", &e))?;
769 let bytes = crate::mail::attachment_bytes(&raw, part_id)?;
770 fs::write(&dest, bytes).map_err(|e| AppError::io("write attachment", &e))?;
771 }
772 let size_bytes = fs::metadata(&dest)
773 .map_err(|e| AppError::io("stat attachment", &e))?
774 .len();
775 message.attachments[pos].fetched = true;
776 message.attachments[pos].file_path = Some(rel_path(&self.root, &dest));
777 self.write_message_materialized_cache(&message)?;
778 Ok(SavedAttachment {
779 part_id: attachment.part_id,
780 filename: attachment.filename,
781 saved_filename: path_file_name(&dest),
782 content_type: attachment.content_type,
783 path: dest,
784 size_bytes,
785 })
786 }
787}
788
789fn draft_body_override(body: Option<&str>, body_file: Option<&str>) -> Result<Option<String>> {
790 match (body, body_file) {
791 (Some(_), Some(_)) => Err(AppError::new(
792 "invalid_request",
793 "--body cannot be used with --body-file",
794 )),
795 (Some(body), None) => Ok(Some(body.to_string())),
796 (None, Some(path)) => {
797 let path = resolve_cli_path(path)?;
798 read_to_string(&path, "read draft body file").map(Some)
799 }
800 (None, None) => Ok(None),
801 }
802}
803
804fn write_draft_validation_state(
805 case_path: &Path,
806 draft_name: &str,
807 validation: &DraftValidation,
808 now: &str,
809) -> Result<()> {
810 let mut draft_state = read_draft_state(case_path)?;
811 let entry = draft_state
812 .drafts
813 .entry(draft_name.to_string())
814 .or_default();
815 entry.last_validated_hash = Some(validation.draft_hash.clone());
816 entry.last_validated_rfc3339 = Some(now.to_string());
817 write_draft_state(case_path, &draft_state)
818}
819
820fn draft_attachment_show_value(case_path: &Path, attachment: &str) -> Value {
821 let Ok(path) = draft_attachment_path(case_path, attachment) else {
822 return json!({
823 "path": attachment,
824 "exists": false,
825 "size_bytes": Value::Null,
826 });
827 };
828 let metadata = fs::metadata(&path).ok();
829 let exists = metadata.as_ref().is_some_and(|metadata| metadata.is_file());
830 let size_bytes = metadata
831 .filter(|metadata| metadata.is_file())
832 .map(|metadata| metadata.len());
833 json!({
834 "path": attachment,
835 "exists": exists,
836 "size_bytes": size_bytes,
837 })
838}
839
840#[derive(Clone, Debug)]
841pub(super) struct DraftValidation {
842 draft_hash: String,
843}
844
845#[derive(Clone, Debug, Default, Deserialize, Serialize)]
846pub(super) struct DraftStateFile {
847 schema_name: String,
848 schema_version: u64,
849 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
850 drafts: BTreeMap<String, DraftStateEntry>,
851}
852
853#[derive(Clone, Debug, Default, Deserialize, Serialize)]
854pub(super) struct DraftStateEntry {
855 #[serde(skip_serializing_if = "Option::is_none")]
856 last_validated_hash: Option<String>,
857 #[serde(skip_serializing_if = "Option::is_none")]
858 last_validated_rfc3339: Option<String>,
859}
860
861#[derive(Debug)]
862pub(super) struct SavedAttachment {
863 part_id: String,
864 filename: String,
865 saved_filename: String,
866 content_type: String,
867 path: PathBuf,
868 size_bytes: u64,
869}
870
871pub(super) fn saved_attachment_value(
872 root: &Path,
873 code: &str,
874 message_id: &str,
875 saved: &SavedAttachment,
876) -> Value {
877 json!({
878 "code": code,
879 "message_id": message_id,
880 "part_id": saved.part_id.as_str(),
881 "filename": saved.filename.as_str(),
882 "saved_filename": saved.saved_filename.as_str(),
883 "content_type": saved.content_type.as_str(),
884 "storage": "message_cache",
885 "file_path": rel_path(root, &saved.path),
886 "size_bytes": saved.size_bytes,
887 })
888}
889
890pub(super) fn saved_filename_for_attachment(attachment: &AttachmentRef) -> String {
891 attachment
892 .file_path
893 .as_deref()
894 .and_then(|path| Path::new(path).file_name())
895 .map(|name| name.to_string_lossy().to_string())
896 .filter(|name| !name.trim().is_empty())
897 .unwrap_or_else(|| safe_attachment_filename(&attachment.filename, &attachment.part_id))
898}
899
900pub(super) fn safe_attachment_filename(filename: &str, part_id: &str) -> String {
901 let fallback = format!("part-{part_id}");
902 let candidate = filename.trim();
903 if candidate.is_empty() {
904 return fallback;
905 }
906 let sanitized = sanitize_with_options(
907 candidate,
908 SanitizeFilenameOptions {
909 windows: true,
910 truncate: true,
911 replacement: "_",
912 },
913 );
914 let sanitized = sanitized.trim();
915 if sanitized.is_empty() {
916 fallback
917 } else {
918 sanitized.to_string()
919 }
920}
921
922pub(super) fn is_image_content_type(content_type: &str) -> bool {
923 content_type
924 .split_once(';')
925 .map(|(mime, _)| mime)
926 .unwrap_or(content_type)
927 .trim()
928 .to_ascii_lowercase()
929 .starts_with("image/")
930}
931
932pub(super) fn attachment_markdown_path(
933 root: Option<&Path>,
934 output_dir: Option<&Path>,
935 file_path: &str,
936) -> String {
937 let Some(root) = root else {
938 return file_path.to_string();
939 };
940 let Some(output_dir) = output_dir else {
941 return file_path.to_string();
942 };
943 let Ok(from) = output_dir.strip_prefix(root) else {
944 return file_path.to_string();
945 };
946 let up_count = from
947 .components()
948 .filter(|component| matches!(component, std::path::Component::Normal(_)))
949 .count();
950 let mut parts = Vec::new();
951 parts.extend(std::iter::repeat_n("..", up_count));
952 parts.extend(file_path.split('/').filter(|part| !part.is_empty()));
953 if parts.is_empty() {
954 ".".to_string()
955 } else {
956 parts.join("/")
957 }
958}
959
960pub(super) fn render_draft_new_body(root: &Path, language: TemplateLanguage) -> Result<String> {
961 render_template(
962 root,
963 language,
964 TemplateKey::DraftNew,
965 &json!({"language": language.as_str()}),
966 )
967}
968
969pub(super) fn render_draft_reply_body(
970 root: &Path,
971 language: TemplateLanguage,
972 sender: Option<&str>,
973 quoted: &str,
974) -> Result<String> {
975 render_template(
976 root,
977 language,
978 TemplateKey::DraftReply,
979 &json!({
980 "language": language.as_str(),
981 "sender": sender.unwrap_or(""),
982 "quoted": quoted,
983 }),
984 )
985}
986
987pub(super) fn read_draft_state(case_path: &Path) -> Result<DraftStateFile> {
988 let path = case_drafts_json_path(case_path);
989 if !path.exists() {
990 return Ok(DraftStateFile {
991 schema_name: "draft_state".to_string(),
992 schema_version: 1,
993 drafts: BTreeMap::new(),
994 });
995 }
996 let data = read_to_string(&path, "read draft state")?;
997 let state: DraftStateFile =
998 serde_json::from_str(&data).map_err(|e| AppError::json("parse draft state", &e))?;
999 if state.schema_name != "draft_state" || state.schema_version != 1 {
1000 return Err(AppError::new(
1001 "draft_state_invalid",
1002 format!("invalid draft state schema: {}", path_to_string(&path)),
1003 ));
1004 }
1005 Ok(state)
1006}
1007
1008pub(super) fn write_draft_state(case_path: &Path, state: &DraftStateFile) -> Result<()> {
1009 let mut normalized = state.clone();
1010 normalized.schema_name = "draft_state".to_string();
1011 normalized.schema_version = 1;
1012 write_json_pretty(&case_drafts_json_path(case_path), &normalized)
1013}
1014
1015pub(super) fn resolve_cli_path(path: &str) -> Result<PathBuf> {
1016 let path = Path::new(path);
1017 if path.is_absolute() {
1018 return Ok(path.to_path_buf());
1019 }
1020 Ok(std::env::current_dir()
1021 .map_err(|e| AppError::io("current dir", &e))?
1022 .join(path))
1023}
1024
1025pub(super) fn draft_attachment_path(case_path: &Path, attachment: &str) -> Result<PathBuf> {
1026 let path = Path::new(attachment);
1027 if attachment.trim().is_empty() || path.is_absolute() {
1028 return Err(AppError::new(
1029 "draft_invalid",
1030 format!("invalid draft attachment path: {attachment}"),
1031 ));
1032 }
1033 let mut safe = PathBuf::new();
1034 for component in path.components() {
1035 match component {
1036 std::path::Component::Normal(part) => safe.push(part),
1037 _ => {
1038 return Err(AppError::new(
1039 "draft_invalid",
1040 format!("invalid draft attachment path: {attachment}"),
1041 ))
1042 }
1043 }
1044 }
1045 if safe.as_os_str().is_empty() {
1046 return Err(AppError::new(
1047 "draft_invalid",
1048 format!("invalid draft attachment path: {attachment}"),
1049 ));
1050 }
1051 Ok(case_path.join(safe))
1052}