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