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