1use super::*;
2
3impl Workspace {
4 pub fn validate_draft(&self, case_ref: &str, draft_name: &str) -> Result<Value> {
5 self.require_workspace()?;
6 let (case_uid, case_path) = self.resolve_active_case(case_ref)?;
7 let validation = self.validate_draft_inner(&case_uid, draft_name, &case_path)?;
8 let now = now_rfc3339();
9 let mut draft_state = read_draft_state(&case_path)?;
10 let entry = draft_state
11 .drafts
12 .entry(draft_name.to_string())
13 .or_default();
14 entry.last_validated_hash = Some(validation.draft_hash.clone());
15 entry.last_validated_rfc3339 = Some(now.clone());
16 write_draft_state(&case_path, &draft_state)?;
17 Ok(json!({
18 "code": "draft_valid",
19 "case_uid": case_uid,
20 "draft_name": draft_name,
21 "draft_hash": validation.draft_hash,
22 "last_validated_rfc3339": now
23 }))
24 }
25
26 pub fn attach_file_to_draft(
27 &self,
28 case_ref: &str,
29 draft_name: &str,
30 source_path: &str,
31 ) -> Result<Value> {
32 self.require_workspace()?;
33 validate_file_name("draft_name", draft_name)?;
34 let (case_uid, case_path) = self.resolve_active_case(case_ref)?;
35 let draft_path = case_path.join("drafts").join(draft_name);
36 if !draft_path.is_file() {
37 return Err(AppError::new(
38 "draft_not_found",
39 format!("draft not found: {draft_name}"),
40 ));
41 }
42
43 let source = resolve_cli_path(source_path)?;
44 if !source.is_file() {
45 return Err(AppError::new(
46 "draft_invalid",
47 format!("draft attachment source is not a file: {source_path}"),
48 ));
49 }
50 let source_abs =
51 fs::canonicalize(&source).map_err(|e| AppError::io("canonicalize attachment", &e))?;
52 let case_abs =
53 fs::canonicalize(&case_path).map_err(|e| AppError::io("canonicalize case", &e))?;
54 let text = read_to_string(&draft_path, "read draft")?;
55 let (mut fm, body) = read_doc::<DraftFrontmatter>(&text)?;
56
57 let (attachment, file_path, copied) = if source_abs.starts_with(&case_abs) {
58 let relative = source_abs
59 .strip_prefix(&case_abs)
60 .map_err(|e| AppError::new("draft_invalid", e.to_string()))?;
61 (
62 path_to_string(relative),
63 rel_path(&self.root, &source_abs),
64 false,
65 )
66 } else {
67 let files_dir = case_path.join("files");
68 create_dir_all(&files_dir)?;
69 let filename = source_abs
70 .file_name()
71 .and_then(|name| name.to_str())
72 .unwrap_or("attachment");
73 let saved_filename = safe_attachment_filename(filename, "attachment");
74 let candidate_attachment = format!("files/{saved_filename}");
75 let already_present = fm
76 .attachments
77 .iter()
78 .any(|item| item == &candidate_attachment);
79 let dest = if already_present && files_dir.join(&saved_filename).is_file() {
80 files_dir.join(&saved_filename)
81 } else {
82 let dest = unique_dest_path(&files_dir, &saved_filename);
83 fs::copy(&source_abs, &dest)
84 .map_err(|e| AppError::io("copy draft attachment", &e))?;
85 dest
86 };
87 (
88 format!("files/{}", path_file_name(&dest)),
89 rel_path(&self.root, &dest),
90 !already_present,
91 )
92 };
93
94 let already_present = fm.attachments.iter().any(|item| item == &attachment);
95 if !already_present {
96 fm.attachments.push(attachment.clone());
97 write_string(&draft_path, &render_frontmatter(&fm, &body)?)?;
98 }
99 let size_bytes = fs::metadata(self.root.join(&file_path))
100 .or_else(|_| fs::metadata(&source_abs))
101 .map_err(|e| AppError::io("stat draft attachment", &e))?
102 .len();
103 Ok(json!({
104 "code": "draft_attachment_added",
105 "case_uid": case_uid,
106 "draft_name": draft_name,
107 "draft_path": rel_path(&self.root, &draft_path),
108 "source_path": path_to_string(&source_abs),
109 "attachment": attachment,
110 "file_path": file_path,
111 "copied": copied,
112 "already_present": already_present,
113 "size_bytes": size_bytes,
114 "requires_validate": true,
115 }))
116 }
117
118 pub fn remove_draft(
119 &self,
120 case_ref: &str,
121 draft_name: &str,
122 reason: Option<&str>,
123 ) -> Result<Value> {
124 self.require_workspace()?;
125 let reason = self.checked_reason(reason)?;
126 validate_file_name("draft_name", draft_name)?;
127 let (case_uid, case_path) = self.resolve_active_case(case_ref)?;
128 let draft_path = case_path.join("drafts").join(draft_name);
129 if !draft_path.is_file() {
130 return Err(AppError::new(
131 "draft_not_found",
132 format!("draft not found: {draft_name}"),
133 ));
134 }
135 let removed_push =
136 crate::push_queue::remove_outbound_for_draft(&self.root, &case_uid, draft_name)?;
137 remove_file(&draft_path)?;
138 let mut draft_state = read_draft_state(&case_path)?;
139 let state_removed = draft_state.drafts.remove(draft_name).is_some();
140 write_draft_state(&case_path, &draft_state)?;
141 let push_ids = removed_push
142 .iter()
143 .map(|item| item.push_id.clone())
144 .collect::<Vec<_>>();
145 let staged_eml_paths = removed_push
146 .iter()
147 .filter_map(|item| item.eml_path.clone())
148 .collect::<Vec<_>>();
149 self.append_audit_event(
150 "draft_removed",
151 vec![audit_target("case", &case_uid)],
152 reason,
153 json!({
154 "case_uid": case_uid,
155 "draft_name": draft_name,
156 "draft_path": rel_path(&self.root, &draft_path),
157 "push_ids": push_ids.clone(),
158 "staged_eml_paths": staged_eml_paths.clone(),
159 "state_removed": state_removed,
160 "mail_sent": false,
161 }),
162 )?;
163 Ok(json!({
164 "code": "draft_removed",
165 "case_uid": case_uid,
166 "draft_name": draft_name,
167 "draft_path": rel_path(&self.root, &draft_path),
168 "draft_deleted": true,
169 "state_removed": state_removed,
170 "queued_removed": !push_ids.is_empty(),
171 "removed_push_count": push_ids.len(),
172 "push_ids": push_ids,
173 "staged_eml_paths": staged_eml_paths,
174 "mail_sent": false
175 }))
176 }
177
178 pub fn compose_draft(&self, case_ref: &str, draft_name: &str) -> Result<Value> {
179 self.require_workspace()?;
180 let (case_uid, case_path) = self.resolve_active_case(case_ref)?;
181 validate_file_name("draft_name", draft_name)?;
182 let draft_path = case_path.join("drafts").join(draft_name);
183 let draft_hash = draft_file_hash(&draft_path)?;
184 let draft_state = read_draft_state(&case_path)?;
185 let entry = draft_state.drafts.get(draft_name).ok_or_else(|| {
186 AppError::new(
187 "draft_validation_required",
188 format!("draft must be validated before compose: {draft_name}"),
189 )
190 .with_hint(format!(
191 "Run `afmail case draft validate {case_uid} {draft_name}` before composing."
192 ))
193 .with_details(json!({
194 "case_uid": case_uid,
195 "draft_name": draft_name,
196 "suggested_commands": [
197 format!("afmail case draft validate {case_uid} {draft_name}"),
198 format!("afmail case compose {case_uid} {draft_name}")
199 ]
200 }))
201 })?;
202 let last_validated_hash = entry.last_validated_hash.as_deref().ok_or_else(|| {
203 AppError::new(
204 "draft_validation_required",
205 format!("draft must be validated before compose: {draft_name}"),
206 )
207 .with_hint(format!(
208 "Run `afmail case draft validate {case_uid} {draft_name}` before composing."
209 ))
210 .with_details(json!({
211 "case_uid": case_uid,
212 "draft_name": draft_name,
213 "suggested_commands": [
214 format!("afmail case draft validate {case_uid} {draft_name}"),
215 format!("afmail case compose {case_uid} {draft_name}")
216 ]
217 }))
218 })?;
219 if last_validated_hash != draft_hash {
220 return Err(AppError::new(
221 "draft_changed_since_validation",
222 format!("draft changed since validation: {draft_name}"),
223 )
224 .with_hint(format!(
225 "Re-run `afmail case draft validate {case_uid} {draft_name}`, then compose again."
226 ))
227 .with_details(json!({
228 "case_uid": case_uid,
229 "draft_name": draft_name,
230 "suggested_commands": [
231 format!("afmail case draft validate {case_uid} {draft_name}"),
232 format!("afmail case compose {case_uid} {draft_name}")
233 ]
234 })));
235 }
236 let config = crate::config::MailConfig::load(&self.root)?;
237 let transaction = self.begin_transaction(
238 "draft_compose",
239 vec![
240 rel_path(&self.root, &draft_path),
241 rel_path(&self.root, &case_drafts_json_path(&case_path)),
242 ".afmail/push".to_string(),
243 ],
244 )?;
245 let mut queued = crate::push_queue::queue_outbound(
246 &self.root,
247 &case_path,
248 &case_uid,
249 draft_name,
250 &draft_hash,
251 &config,
252 )?;
253 let push_id = queued
254 .get("push_id")
255 .and_then(Value::as_str)
256 .map(ToString::to_string)
257 .unwrap_or_default();
258 let now = now_rfc3339();
259 let mut draft_state = read_draft_state(&case_path)?;
260 let entry = draft_state
261 .drafts
262 .entry(draft_name.to_string())
263 .or_default();
264 entry.last_composed_hash = Some(draft_hash.clone());
265 entry.last_composed_rfc3339 = Some(now.clone());
266 if !push_id.is_empty() {
267 entry.push_id = Some(push_id);
268 }
269 write_draft_state(&case_path, &draft_state)?;
270 if let Some(object) = queued.as_object_mut() {
271 object.insert("draft_hash".to_string(), json!(draft_hash));
272 object.insert("last_composed_rfc3339".to_string(), json!(now));
273 }
274 transaction.commit()?;
275 Ok(queued)
276 }
277
278 pub fn reply_to_message(
279 &self,
280 case_ref: &str,
281 message_id: &str,
282 reply_all: bool,
283 ) -> Result<Value> {
284 self.require_workspace()?;
285 validate_id("message_id", message_id)?;
286 let (case_uid, case_path) = self.resolve_active_case(case_ref)?;
287 let messages = read_case_messages(&case_messages_json_path(&case_path), &case_uid)?;
288 if !messages.message_ids.iter().any(|id| id == message_id) {
289 return Err(AppError::new(
290 "invalid_request",
291 format!("message does not belong to case: {message_id}"),
292 ));
293 }
294 let message = self.read_message_by_id(message_id)?;
295 let original_subject = message.subject.as_deref().unwrap_or("");
296 let subject = if original_subject
297 .trim_start()
298 .to_lowercase()
299 .starts_with("re:")
300 {
301 original_subject.to_string()
302 } else {
303 format!("Re: {original_subject}")
304 };
305 let config = MailConfig::load(&self.root)?;
308 let own_email = config
309 .smtp
310 .from
311 .as_deref()
312 .or(config.imap.username.as_deref())
313 .map(email_address);
314 let mut seen: BTreeSet<String> = BTreeSet::new();
315 if let Some(own) = &own_email {
316 seen.insert(own.clone());
317 }
318 let mut to: Vec<String> = Vec::new();
319 let mut to_sources: Vec<&String> = if message.reply_to.is_empty() {
320 message.from.iter().collect()
321 } else {
322 message.reply_to.iter().collect()
323 };
324 if reply_all {
325 to_sources.extend(message.to.iter());
326 }
327 for addr in to_sources {
328 let key = email_address(addr);
329 if !key.is_empty() && seen.insert(key) {
330 to.push(addr.clone());
331 }
332 }
333 let mut cc: Vec<String> = Vec::new();
334 if reply_all {
335 for addr in &message.cc {
336 let key = email_address(addr);
337 if !key.is_empty() && seen.insert(key) {
338 cc.push(addr.clone());
339 }
340 }
341 }
342 let fm = DraftFrontmatter {
343 kind: Some("draft".to_string()),
344 case_uid: case_uid.to_string(),
345 send_intent: Some("reply".to_string()),
346 reply_to_message_id: Some(message_id.to_string()),
347 subject: Some(subject),
348 to,
349 cc,
350 attachments: Vec::new(),
351 };
352 let quoted = self.quoted_message_body(&message)?;
353 let body = render_draft_reply_body(
354 &self.root,
355 config.template_language(),
356 message.from.as_deref(),
357 "ed,
358 )?;
359 let draft_name = format!("reply-{message_id}.md");
360 let draft_path = case_path.join("drafts").join(&draft_name);
361 if draft_path.exists() {
362 return Err(AppError::new(
363 "draft_exists",
364 format!("reply draft already exists: {draft_name}"),
365 ));
366 }
367 create_dir_all(&case_path.join("drafts"))?;
368 write_string_new(&draft_path, &render_frontmatter(&fm, &body)?)?;
369 Ok(json!({
370 "code": "draft_created",
371 "case_uid": case_uid,
372 "message_id": message_id,
373 "draft_name": draft_name,
374 "draft_path": rel_path(&self.root, &draft_path)
375 }))
376 }
377
378 pub fn create_draft(
379 &self,
380 case_ref: &str,
381 to: &[String],
382 cc: &[String],
383 subject: Option<&str>,
384 ) -> Result<Value> {
385 self.require_workspace()?;
386 let (case_uid, case_path) = self.resolve_active_case(case_ref)?;
387 if to.is_empty() {
388 return Err(AppError::new(
389 "invalid_request",
390 "draft requires at least one --to recipient",
391 ));
392 }
393 let fm = DraftFrontmatter {
394 kind: Some("draft".to_string()),
395 case_uid: case_uid.to_string(),
396 send_intent: Some("new".to_string()),
397 reply_to_message_id: None,
398 subject: subject.map(ToString::to_string),
399 to: to.to_vec(),
400 cc: cc.to_vec(),
401 attachments: Vec::new(),
402 };
403 let slug = subject
404 .map(crate::mail::slugify)
405 .filter(|slug| !slug.is_empty())
406 .unwrap_or_else(|| "message".to_string());
407 let drafts_dir = case_path.join("drafts");
408 create_dir_all(&drafts_dir)?;
409 let mut draft_name = format!("new-{slug}.md");
410 let mut counter = 1;
411 while drafts_dir.join(&draft_name).exists() {
412 counter += 1;
413 draft_name = format!("new-{slug}-{counter}.md");
414 }
415 let draft_path = drafts_dir.join(&draft_name);
416 let language = self.template_language()?;
417 let body = render_draft_new_body(&self.root, language)?;
418 write_string_new(&draft_path, &render_frontmatter(&fm, &body)?)?;
419 Ok(json!({
420 "code": "draft_created",
421 "case_uid": case_uid,
422 "draft_name": draft_name,
423 "draft_path": rel_path(&self.root, &draft_path)
424 }))
425 }
426
427 fn quoted_message_body(&self, message: &MessageFile) -> Result<String> {
428 let quoted = message
429 .body_text
430 .lines()
431 .map(|line| {
432 if line.is_empty() {
433 ">".to_string()
434 } else {
435 format!("> {line}")
436 }
437 })
438 .collect::<Vec<_>>()
439 .join("\n");
440 Ok(quoted)
441 }
442
443 pub fn fetch_message_attachment(
444 &self,
445 message_id: &str,
446 part_id: Option<&str>,
447 ) -> Result<Value> {
448 self.require_workspace()?;
449 validate_id("message_id", message_id)?;
450 let dest = self
451 .root
452 .join(format!(".afmail/messages/{message_id}.files"));
453 match part_id {
454 Some(part_id) => {
455 let saved = self.fetch_attachment_to(message_id, part_id, &dest)?;
456 self.refresh_read_views_after_message_change(message_id)?;
457 Ok(saved_attachment_value(
458 &self.root,
459 "attachment_saved",
460 message_id,
461 &saved,
462 ))
463 }
464 None => {
465 let message = self.read_message_by_id(message_id)?;
466 let mut items = Vec::new();
467 for attachment in &message.attachments {
468 let saved = self.fetch_attachment_to(message_id, &attachment.part_id, &dest)?;
469 items.push(saved_attachment_value(
470 &self.root,
471 "attachment_saved",
472 message_id,
473 &saved,
474 ));
475 }
476 self.refresh_read_views_after_message_change(message_id)?;
477 Ok(json!({
478 "code": "attachments_saved",
479 "message_id": message_id,
480 "count": items.len(),
481 "items": items,
482 }))
483 }
484 }
485 }
486
487 fn validate_draft_inner(
488 &self,
489 case_uid: &str,
490 draft_name: &str,
491 case_path: &Path,
492 ) -> Result<DraftValidation> {
493 validate_file_name("draft_name", draft_name)?;
494 let draft_path = case_path.join("drafts").join(draft_name);
495 let draft_bytes = fs::read(&draft_path).map_err(|e| AppError::io("read draft", &e))?;
496 let draft_hash = sha256_fingerprint(&draft_bytes);
497 let draft = std::str::from_utf8(&draft_bytes)
498 .map_err(|e| AppError::new("draft_invalid", format!("draft is not UTF-8: {e}")))?;
499 let (fm, _) = read_doc::<DraftFrontmatter>(draft).map_err(|e| {
500 AppError::new("draft_invalid", format!("invalid draft frontmatter: {e}"))
501 })?;
502 if fm.kind.as_deref() != Some("draft") {
503 return Err(AppError::new("draft_invalid", "draft kind must be draft"));
504 }
505 if fm.case_uid != case_uid {
506 return Err(AppError::new(
507 "draft_invalid",
508 "draft case_uid does not match case",
509 ));
510 }
511 if fm.subject.is_none() {
512 return Err(AppError::new("draft_invalid", "draft subject is required"));
513 }
514 if fm.to.is_empty() {
515 return Err(AppError::new("draft_invalid", "draft to is required"));
516 }
517 if let Some(reply_id) = fm.reply_to_message_id.as_ref() {
518 let messages = read_case_messages(&case_messages_json_path(case_path), case_uid)?;
519 if !messages.message_ids.contains(reply_id) {
520 return Err(AppError::new(
521 "draft_invalid",
522 format!("reply_to_message_id does not belong to case: {reply_id}"),
523 ));
524 }
525 }
526 for attachment in &fm.attachments {
527 let attachment_path = draft_attachment_path(case_path, attachment)?;
528 if !attachment_path.is_file() {
529 return Err(AppError::new(
530 "draft_invalid",
531 format!("draft attachment does not exist: {attachment}"),
532 ));
533 }
534 }
535 Ok(DraftValidation { draft_hash })
536 }
537
538 fn fetch_attachment_to(
539 &self,
540 message_id: &str,
541 part_id: &str,
542 dest_dir: &Path,
543 ) -> Result<SavedAttachment> {
544 validate_id("message_id", message_id)?;
545 let mut message = self.read_message_by_id(message_id)?;
546 let Some(pos) = message
547 .attachments
548 .iter()
549 .position(|a| a.part_id == part_id)
550 else {
551 return Err(AppError::new(
552 "attachment_not_found",
553 format!("attachment not found: {message_id} part {part_id}"),
554 ));
555 };
556 let attachment = message.attachments[pos].clone();
557 create_dir_all(dest_dir)?;
558 if attachment.fetched {
559 if let Some(file_path) = attachment.file_path.as_deref() {
560 let existing = self.root.join(file_path);
561 if existing.is_file() {
562 let size_bytes = fs::metadata(&existing)
563 .map_err(|e| AppError::io("stat attachment", &e))?
564 .len();
565 return Ok(SavedAttachment {
566 part_id: attachment.part_id,
567 filename: attachment.filename,
568 saved_filename: path_file_name(&existing),
569 content_type: attachment.content_type,
570 path: existing,
571 size_bytes,
572 });
573 }
574 }
575 }
576 let saved_filename = safe_attachment_filename(&attachment.filename, part_id);
577 let dest = unique_dest_path(dest_dir, &saved_filename);
578 if let Some(source_path) = attachment.source_path.clone() {
579 fs::copy(self.root.join(source_path), &dest)
580 .map_err(|e| AppError::io("copy attachment", &e))?;
581 } else {
582 let eml_path = message
583 .eml_path
584 .clone()
585 .unwrap_or_else(|| format!(".afmail/messages/{message_id}.eml"));
586 let raw =
587 fs::read(self.root.join(eml_path)).map_err(|e| AppError::io("read eml", &e))?;
588 let bytes = crate::mail::attachment_bytes(&raw, part_id)?;
589 fs::write(&dest, bytes).map_err(|e| AppError::io("write attachment", &e))?;
590 }
591 let size_bytes = fs::metadata(&dest)
592 .map_err(|e| AppError::io("stat attachment", &e))?
593 .len();
594 message.attachments[pos].fetched = true;
595 message.attachments[pos].file_path = Some(rel_path(&self.root, &dest));
596 self.write_message_materialized_cache(&message)?;
597 Ok(SavedAttachment {
598 part_id: attachment.part_id,
599 filename: attachment.filename,
600 saved_filename: path_file_name(&dest),
601 content_type: attachment.content_type,
602 path: dest,
603 size_bytes,
604 })
605 }
606}
607
608#[derive(Clone, Debug)]
609pub(super) struct DraftValidation {
610 draft_hash: String,
611}
612
613#[derive(Clone, Debug, Default, Deserialize, Serialize)]
614pub(super) struct DraftStateFile {
615 schema_name: String,
616 schema_version: u64,
617 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
618 drafts: BTreeMap<String, DraftStateEntry>,
619}
620
621#[derive(Clone, Debug, Default, Deserialize, Serialize)]
622pub(super) struct DraftStateEntry {
623 #[serde(skip_serializing_if = "Option::is_none")]
624 last_validated_hash: Option<String>,
625 #[serde(skip_serializing_if = "Option::is_none")]
626 last_validated_rfc3339: Option<String>,
627 #[serde(skip_serializing_if = "Option::is_none")]
628 last_composed_hash: Option<String>,
629 #[serde(skip_serializing_if = "Option::is_none")]
630 last_composed_rfc3339: Option<String>,
631 #[serde(skip_serializing_if = "Option::is_none")]
632 push_id: Option<String>,
633}
634
635#[derive(Debug)]
636pub(super) struct SavedAttachment {
637 part_id: String,
638 filename: String,
639 saved_filename: String,
640 content_type: String,
641 path: PathBuf,
642 size_bytes: u64,
643}
644
645pub(super) fn saved_attachment_value(
646 root: &Path,
647 code: &str,
648 message_id: &str,
649 saved: &SavedAttachment,
650) -> Value {
651 json!({
652 "code": code,
653 "message_id": message_id,
654 "part_id": saved.part_id.as_str(),
655 "filename": saved.filename.as_str(),
656 "saved_filename": saved.saved_filename.as_str(),
657 "content_type": saved.content_type.as_str(),
658 "storage": "message_cache",
659 "file_path": rel_path(root, &saved.path),
660 "size_bytes": saved.size_bytes,
661 })
662}
663
664pub(super) fn saved_filename_for_attachment(attachment: &AttachmentRef) -> String {
665 attachment
666 .file_path
667 .as_deref()
668 .and_then(|path| Path::new(path).file_name())
669 .map(|name| name.to_string_lossy().to_string())
670 .filter(|name| !name.trim().is_empty())
671 .unwrap_or_else(|| safe_attachment_filename(&attachment.filename, &attachment.part_id))
672}
673
674pub(super) fn safe_attachment_filename(filename: &str, part_id: &str) -> String {
675 let fallback = format!("part-{part_id}");
676 let candidate = filename.trim();
677 if candidate.is_empty() {
678 return fallback;
679 }
680 let sanitized = sanitize_with_options(
681 candidate,
682 SanitizeFilenameOptions {
683 windows: true,
684 truncate: true,
685 replacement: "_",
686 },
687 );
688 let sanitized = sanitized.trim();
689 if sanitized.is_empty() {
690 fallback
691 } else {
692 sanitized.to_string()
693 }
694}
695
696pub(super) fn is_image_content_type(content_type: &str) -> bool {
697 content_type
698 .split_once(';')
699 .map(|(mime, _)| mime)
700 .unwrap_or(content_type)
701 .trim()
702 .to_ascii_lowercase()
703 .starts_with("image/")
704}
705
706pub(super) fn attachment_markdown_path(
707 root: Option<&Path>,
708 output_dir: Option<&Path>,
709 file_path: &str,
710) -> String {
711 let Some(root) = root else {
712 return file_path.to_string();
713 };
714 let Some(output_dir) = output_dir else {
715 return file_path.to_string();
716 };
717 let Ok(from) = output_dir.strip_prefix(root) else {
718 return file_path.to_string();
719 };
720 let up_count = from
721 .components()
722 .filter(|component| matches!(component, std::path::Component::Normal(_)))
723 .count();
724 let mut parts = Vec::new();
725 parts.extend(std::iter::repeat_n("..", up_count));
726 parts.extend(file_path.split('/').filter(|part| !part.is_empty()));
727 if parts.is_empty() {
728 ".".to_string()
729 } else {
730 parts.join("/")
731 }
732}
733
734pub(super) fn render_draft_new_body(root: &Path, language: TemplateLanguage) -> Result<String> {
735 render_template(
736 root,
737 language,
738 TemplateKey::DraftNew,
739 &json!({"language": language.as_str()}),
740 )
741}
742
743pub(super) fn render_draft_reply_body(
744 root: &Path,
745 language: TemplateLanguage,
746 sender: Option<&str>,
747 quoted: &str,
748) -> Result<String> {
749 render_template(
750 root,
751 language,
752 TemplateKey::DraftReply,
753 &json!({
754 "language": language.as_str(),
755 "sender": sender.unwrap_or(""),
756 "quoted": quoted,
757 }),
758 )
759}
760
761pub(super) fn read_draft_state(case_path: &Path) -> Result<DraftStateFile> {
762 let path = case_drafts_json_path(case_path);
763 if !path.exists() {
764 return Ok(DraftStateFile {
765 schema_name: "draft_state".to_string(),
766 schema_version: 1,
767 drafts: BTreeMap::new(),
768 });
769 }
770 let data = read_to_string(&path, "read draft state")?;
771 let state: DraftStateFile =
772 serde_json::from_str(&data).map_err(|e| AppError::json("parse draft state", &e))?;
773 if state.schema_name != "draft_state" || state.schema_version != 1 {
774 return Err(AppError::new(
775 "draft_state_invalid",
776 format!("invalid draft state schema: {}", path_to_string(&path)),
777 ));
778 }
779 Ok(state)
780}
781
782pub(super) fn write_draft_state(case_path: &Path, state: &DraftStateFile) -> Result<()> {
783 let mut normalized = state.clone();
784 normalized.schema_name = "draft_state".to_string();
785 normalized.schema_version = 1;
786 write_json_pretty(&case_drafts_json_path(case_path), &normalized)
787}
788
789pub(super) fn draft_file_hash(path: &Path) -> Result<String> {
790 let bytes = fs::read(path).map_err(|e| AppError::io("read draft", &e))?;
791 Ok(sha256_fingerprint(&bytes))
792}
793
794pub(super) fn resolve_cli_path(path: &str) -> Result<PathBuf> {
795 let path = Path::new(path);
796 if path.is_absolute() {
797 return Ok(path.to_path_buf());
798 }
799 Ok(std::env::current_dir()
800 .map_err(|e| AppError::io("current dir", &e))?
801 .join(path))
802}
803
804pub(super) fn draft_attachment_path(case_path: &Path, attachment: &str) -> Result<PathBuf> {
805 let path = Path::new(attachment);
806 if attachment.trim().is_empty() || path.is_absolute() {
807 return Err(AppError::new(
808 "draft_invalid",
809 format!("invalid draft attachment path: {attachment}"),
810 ));
811 }
812 let mut safe = PathBuf::new();
813 for component in path.components() {
814 match component {
815 std::path::Component::Normal(part) => safe.push(part),
816 _ => {
817 return Err(AppError::new(
818 "draft_invalid",
819 format!("invalid draft attachment path: {attachment}"),
820 ))
821 }
822 }
823 }
824 if safe.as_os_str().is_empty() {
825 return Err(AppError::new(
826 "draft_invalid",
827 format!("invalid draft attachment path: {attachment}"),
828 ));
829 }
830 Ok(case_path.join(safe))
831}