Skip to main content

agent_first_mail/store/
drafts.rs

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        // Prefer Reply-To over From. With `reply_all`, also carry the original To
306        // recipients into `to` and the original Cc into `cc`, excluding self.
307        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            &quoted,
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}