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