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