Skip to main content

agent_first_mail/store/
drafts.rs

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