Skip to main content

agent_first_mail/store/
triage.rs

1use super::*;
2
3impl Workspace {
4    /// List untriaged messages by stable locator. Resolve details with
5    /// path_templates or `afmail message show`.
6    pub fn triage_list(&self) -> Result<Value> {
7        self.require_workspace()?;
8        let triage_dir = self.root.join("triage");
9        let mut items = Vec::new();
10        if triage_dir.exists() {
11            let mut paths: Vec<PathBuf> = read_dir(&triage_dir, "read triage directory")?
12                .into_iter()
13                .map(|entry| entry.path())
14                .filter(|path| path.extension().and_then(|s| s.to_str()) == Some("md"))
15                .collect();
16            paths.sort();
17            for path in paths {
18                let message_id = path
19                    .file_stem()
20                    .map(|stem| stem.to_string_lossy().to_string())
21                    .unwrap_or_default();
22                items.push(json!({"message_id": message_id}));
23            }
24        }
25        let push_status = serde_json::to_value(crate::push_queue::push_status(&self.root)?)
26            .map_err(|e| AppError::json("serialize push status", &e))?;
27        Ok(json!({
28            "code": "triage_list",
29            "count": items.len(),
30            "push_status": push_status,
31            "path_templates": {
32                "view_path": "triage/{message_id}.md",
33                "json_path": "messages/{message_id}.json",
34            },
35            "items": items,
36        }))
37    }
38
39    pub fn refresh_triage_views(&self) -> Result<Value> {
40        self.require_workspace()?;
41        create_dir_all(&self.root.join("triage"))?;
42        let cases = CaseIndex::build(self)?;
43        let mut desired = BTreeSet::new();
44        let mut written_count = 0usize;
45        for path in message_json_paths(&self.root)? {
46            let mut message = read_message(&path)?;
47            let status = self.derived_message_status(&message, &cases)?;
48            if message.workspace.status != status {
49                message.workspace.status = status;
50                message.workspace.remote_sync = None;
51                self.write_message_cache(&message)?;
52            }
53            if self.triage_candidate(&message, &cases)? {
54                desired.insert(message.message_id.clone());
55                self.write_triage_view(&message)?;
56                written_count += 1;
57            }
58        }
59        let stale_count = self.remove_stale_triage_views(&desired)?;
60        Ok(json!({
61            "code": "triage_refreshed",
62            "triage_count": desired.len(),
63            "triage_written_count": written_count,
64            "stale_triage_removed_count": stale_count
65        }))
66    }
67}
68
69pub(crate) fn render_triage_view(
70    root: &Path,
71    language: TemplateLanguage,
72    message: &MessageFile,
73    conversation: &str,
74    suggested_case_uids: Vec<String>,
75    suggested_reason: Option<String>,
76    related_messages: Vec<Value>,
77) -> Result<String> {
78    let generated_rfc3339 = now_rfc3339();
79    render_template(
80        root,
81        language,
82        TemplateKey::TriageView,
83        &json!({
84            "language": language.as_str(),
85            "message_id": message.message_id.as_str(),
86            "title": message.subject.as_deref().unwrap_or(""),
87            "generated_rfc3339": generated_rfc3339,
88            "attachment_count": message.attachments.len(),
89            "suggested_case_uids": suggested_case_uids,
90            "suggested_reason": suggested_reason.as_deref().unwrap_or(""),
91            "suggested_reason_yaml": suggested_reason
92                .as_deref()
93                .map(yaml_double_quote)
94                .unwrap_or_default(),
95            "related_messages": related_messages,
96            "conversation": conversation.trim(),
97            "message": message_template_value(message)?,
98        }),
99    )
100}
101
102pub(super) fn yaml_double_quote(value: &str) -> String {
103    value.replace('\\', "\\\\").replace('"', "\\\"")
104}
105
106impl Workspace {
107    pub(super) fn refresh_all_case_message_views(&self) -> Result<usize> {
108        let language = self.template_language()?;
109        let mut renderer = MarkdownTemplateRenderer::new(&self.root, language);
110        let mut count = 0usize;
111        for (_, case_path) in self.all_case_entries()? {
112            self.refresh_case_message_views_with_renderer(&case_path, &mut renderer)?;
113            count += 1;
114        }
115        Ok(count)
116    }
117
118    pub(super) fn refresh_case_message_views(&self, case_path: &Path) -> Result<()> {
119        let language = self.template_language()?;
120        let mut renderer = MarkdownTemplateRenderer::new(&self.root, language);
121        self.refresh_case_message_views_with_renderer(case_path, &mut renderer)?;
122        Ok(())
123    }
124
125    pub(super) fn refresh_case_message_views_with_renderer(
126        &self,
127        case_path: &Path,
128        renderer: &mut MarkdownTemplateRenderer<'_>,
129    ) -> Result<CaseViewRefresh> {
130        if !case_json_path(case_path).is_file() {
131            return Ok(CaseViewRefresh::default());
132        }
133        let case_fm = read_case_file(case_path)?;
134        let case_uid = case_fm.case_uid.clone();
135        let case_messages = read_case_messages(&case_messages_json_path(case_path), &case_uid)?;
136        let messages_dir = case_views_messages_dir(case_path);
137        create_dir_all(&messages_dir)?;
138        let config = MailConfig::load(&self.root)?;
139        let mut desired = BTreeSet::new();
140        let mut case_conversation = Vec::new();
141        let mut messages = Vec::new();
142        let mut message_count = 0usize;
143        for message_id in &case_messages.message_ids {
144            let message = self.read_message_by_id(message_id)?;
145            desired.insert(message_id.clone());
146            case_conversation.push(self.message_conversation_with_renderer(
147                &message,
148                &config,
149                renderer,
150                Some(case_path),
151            )?);
152            let view_path = case_message_view_path(case_path, message_id);
153            let view = self.render_case_message_view(
154                &case_uid,
155                &case_fm.case_name,
156                &message,
157                renderer,
158                view_path.parent(),
159            )?;
160            write_string(&view_path, &view)?;
161            messages.push(message);
162            message_count += 1;
163        }
164        self.remove_stale_case_message_views(&messages_dir, &desired)?;
165        let case_doc = self.render_case_document(
166            &case_fm,
167            &messages,
168            &case_conversation.join("\n\n"),
169            &config,
170            renderer,
171        )?;
172        write_string(&case_path.join("case.md"), &case_doc)?;
173        Ok(CaseViewRefresh {
174            case_index_count: 1,
175            case_message_count: message_count,
176        })
177    }
178
179    pub(super) fn render_case_message_view(
180        &self,
181        case_uid: &str,
182        case_name: &str,
183        message: &MessageFile,
184        renderer: &mut MarkdownTemplateRenderer<'_>,
185        output_dir: Option<&Path>,
186    ) -> Result<String> {
187        let config = MailConfig::load(&self.root)?;
188        let title = message.subject.as_deref().unwrap_or("");
189        let generated_rfc3339 = now_rfc3339();
190        let message_value = message_template_value(message)?;
191        let conversation =
192            self.message_conversation_with_renderer(message, &config, renderer, output_dir)?;
193        let context = json!({
194            "frontmatter": {
195                "kind": "case_message",
196                "case_uid": case_uid,
197                "case_name": case_name,
198                "message_id": message.message_id.as_str(),
199                "generated_rfc3339": generated_rfc3339.as_str(),
200            },
201            "language": config.resolved_language_bcp47(),
202            "case_uid": case_uid,
203            "case_name": case_name,
204            "message_id": message.message_id.as_str(),
205            "title": title,
206            "generated_rfc3339": generated_rfc3339.as_str(),
207            "conversation": conversation.trim(),
208            "message": message_value,
209        });
210        renderer.render(TemplateKey::CaseMessage, &context)
211    }
212
213    pub(super) fn render_case_document(
214        &self,
215        case_fm: &CaseFrontmatter,
216        messages: &[MessageFile],
217        conversation: &str,
218        config: &MailConfig,
219        renderer: &mut MarkdownTemplateRenderer<'_>,
220    ) -> Result<String> {
221        let case_uid = case_fm.case_uid.as_str();
222        let mut case_view = case_fm.clone();
223        case_view.message_count = messages.len();
224        case_view.attachment_count = messages
225            .iter()
226            .map(|message| message.attachments.len())
227            .sum::<usize>();
228        case_view.last_message_rfc3339 = messages
229            .iter()
230            .filter_map(message_time)
231            .max_by(|a, b| compare_rfc3339_asc(a, b));
232        let mut sorted = messages.to_vec();
233        sorted.sort_by(compare_message_time_asc);
234        let mut items = Vec::new();
235        let offset = config.resolved_timezone_offset();
236        for message in &sorted {
237            let mut fields = Vec::new();
238            let display_time = message_time_datetime(message, &offset).unwrap_or_default();
239            let display_from = message
240                .from
241                .as_deref()
242                .map(markdown_inline)
243                .unwrap_or_default();
244            let display_to = markdown_inline(&message.to.join(", "));
245            let display_subject = message
246                .subject
247                .as_deref()
248                .map(markdown_inline)
249                .unwrap_or_default();
250            let display_status = markdown_inline(&message.workspace.status);
251            if !display_time.is_empty() {
252                fields.push(json!({"kind": "time", "value": display_time.as_str()}));
253            }
254            if !display_from.is_empty() {
255                fields.push(json!({"kind": "from", "value": display_from.as_str()}));
256            }
257            if !display_to.is_empty() {
258                fields.push(json!({"kind": "to", "value": display_to.as_str()}));
259            }
260            if !display_subject.is_empty() {
261                fields.push(json!({"kind": "subject", "value": display_subject.as_str()}));
262            }
263            if !display_status.is_empty() {
264                fields.push(json!({"kind": "status", "value": display_status.as_str()}));
265            }
266            let title = message
267                .subject
268                .as_deref()
269                .filter(|value| !value.trim().is_empty())
270                .unwrap_or(message.message_id.as_str())
271                .to_string();
272            let mut item = thread_item_common(
273                message,
274                &offset,
275                config.template_language(),
276                format!("views/messages/{}.md", message.message_id),
277                title,
278            )?;
279            if let Value::Object(map) = &mut item {
280                map.insert("fields".to_string(), json!(fields));
281            }
282            items.push(item);
283        }
284        let case_value = serde_json::to_value(&case_view)
285            .map_err(|e| AppError::json("serialize case frontmatter", &e))?;
286        let generated_rfc3339 = now_rfc3339();
287        let messages_value = sorted
288            .iter()
289            .map(message_template_value)
290            .collect::<Result<Vec<_>>>()?;
291        let context = json!({
292            "frontmatter": {
293                "kind": "case_index",
294                "case_uid": case_uid,
295                "case_name": case_fm.case_name.as_str(),
296                "generated_rfc3339": generated_rfc3339.as_str(),
297                "message_count": items.len(),
298            },
299            "language": config.resolved_language_bcp47(),
300            "case_uid": case_uid,
301            "case_name": case_fm.case_name.as_str(),
302            "title": case_fm.case_name.as_str(),
303            "status": case_fm.status.as_str(),
304            "message_count": items.len(),
305            "generated_rfc3339": generated_rfc3339.as_str(),
306            "case": case_value,
307            "items": items,
308            "messages": messages_value,
309            "conversation": conversation.trim(),
310        });
311        renderer.render(TemplateKey::CaseDocument, &context)
312    }
313
314    pub(super) fn remove_stale_case_message_views(
315        &self,
316        messages_dir: &Path,
317        desired: &BTreeSet<String>,
318    ) -> Result<()> {
319        for entry in read_dir(messages_dir, "read case message views")? {
320            let path = entry.path();
321            if path.extension().and_then(|s| s.to_str()) != Some("md") {
322                continue;
323            }
324            let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
325                continue;
326            };
327            if !desired.contains(stem) {
328                remove_file(&path)?;
329            }
330        }
331        Ok(())
332    }
333
334    pub(super) fn derived_message_status(
335        &self,
336        message: &MessageFile,
337        cases: &CaseIndex,
338    ) -> Result<String> {
339        let current = MessageStatus::parse(&message.workspace.status)?;
340        if current.is_terminal_local() {
341            return Ok(current.as_str().to_string());
342        }
343        if message.workspace.archive_uid.is_some() || current == MessageStatus::Archived {
344            return Ok(MessageStatus::Archived.as_str().to_string());
345        }
346        if cases.has_active_reference(&message.message_id) {
347            return Ok(MessageStatus::Case.as_str().to_string());
348        }
349        if message.workspace.origin.is_some() {
350            return Ok(MessageStatus::Archived.as_str().to_string());
351        }
352        if cases.has_any_reference(&message.message_id) {
353            return Ok(MessageStatus::Case.as_str().to_string());
354        }
355        Ok(MessageStatus::Triage.as_str().to_string())
356    }
357
358    pub(super) fn triage_candidate(
359        &self,
360        message: &MessageFile,
361        cases: &CaseIndex,
362    ) -> Result<bool> {
363        let current = MessageStatus::parse(&message.workspace.status)?;
364        if current.is_terminal_local() {
365            return Ok(false);
366        }
367        if message.workspace.archive_uid.is_some() || current == MessageStatus::Archived {
368            return Ok(false);
369        }
370        if message.workspace.origin.is_some() {
371            return Ok(false);
372        }
373        Ok(!cases.has_any_reference(&message.message_id))
374    }
375
376    pub(super) fn write_triage_view(&self, message: &MessageFile) -> Result<()> {
377        let path = self
378            .root
379            .join("triage")
380            .join(format!("{}.md", message.message_id));
381        let conversation = self.message_conversation_for_dir(message, path.parent())?;
382        let (suggested_case_uids, suggested_reason) = existing_triage_suggestion(&path)?;
383        let related_message_ids = self.related_message_ids(&message.message_id)?;
384        let related_messages = self.related_message_rows(&related_message_ids)?;
385        let config = MailConfig::load(&self.root)?;
386        let rendered = render_triage_view(
387            &self.root,
388            config.template_language(),
389            message,
390            &conversation,
391            suggested_case_uids,
392            suggested_reason,
393            related_messages,
394        )?;
395        write_string(&path, &rendered)
396    }
397
398    pub(super) fn related_message_rows(
399        &self,
400        related_message_ids: &[String],
401    ) -> Result<Vec<Value>> {
402        let mut out = Vec::new();
403        for related_message_id in related_message_ids {
404            let related = self.read_message_by_id(related_message_id)?;
405            let time = related
406                .received_rfc3339
407                .as_deref()
408                .or(related.sent_rfc3339.as_deref())
409                .unwrap_or("");
410            out.push(json!({
411                "message_id": related.message_id,
412                "direction": markdown_table_cell(related.direction.as_deref().unwrap_or("")),
413                "from": markdown_table_cell(related.from.as_deref().unwrap_or("")),
414                "subject": markdown_table_cell(related.subject.as_deref().unwrap_or("")),
415                "time": markdown_table_cell(time),
416                "status": markdown_table_cell(&related.workspace.status),
417                "message": message_template_value(&related)?,
418            }));
419        }
420        Ok(out)
421    }
422
423    pub(super) fn remove_stale_triage_views(&self, desired: &BTreeSet<String>) -> Result<usize> {
424        let triage_dir = self.root.join("triage");
425        if !triage_dir.exists() {
426            return Ok(0);
427        }
428        let mut removed = 0usize;
429        for entry in read_dir(&triage_dir, "read triage directory")? {
430            let path = entry.path();
431            if path.extension().and_then(|s| s.to_str()) != Some("md") {
432                continue;
433            }
434            let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
435                continue;
436            };
437            if !desired.contains(stem) {
438                remove_file(&path)?;
439                removed += 1;
440            }
441        }
442        Ok(removed)
443    }
444}