Skip to main content

agent_first_mail/store/
doctor.rs

1use super::*;
2
3#[derive(Clone, Debug, Serialize)]
4struct DoctorIssue {
5    code: String,
6    severity: &'static str,
7    message: String,
8    #[serde(skip_serializing_if = "Option::is_none")]
9    path: Option<String>,
10    #[serde(default, skip_serializing_if = "Vec::is_empty")]
11    refs: Vec<String>,
12    repairable: bool,
13}
14
15impl DoctorIssue {
16    fn error(code: &str, message: impl Into<String>, path: Option<String>) -> Self {
17        Self {
18            code: code.to_string(),
19            severity: "error",
20            message: message.into(),
21            path,
22            refs: Vec::new(),
23            repairable: false,
24        }
25    }
26
27    fn warning(
28        code: &str,
29        message: impl Into<String>,
30        path: Option<String>,
31        repairable: bool,
32    ) -> Self {
33        Self {
34            code: code.to_string(),
35            severity: "warning",
36            message: message.into(),
37            path,
38            refs: Vec::new(),
39            repairable,
40        }
41    }
42}
43
44impl Workspace {
45    pub fn doctor(&self) -> Result<Value> {
46        self.require_workspace()?;
47        let issues = self.doctor_issues()?;
48        let repairable_count = issues.iter().filter(|issue| issue.repairable).count();
49        let error_count = issues
50            .iter()
51            .filter(|issue| issue.severity == "error")
52            .count();
53        Ok(json!({
54            "code": "doctor",
55            "ok": issues.is_empty(),
56            "issue_count": issues.len(),
57            "error_count": error_count,
58            "repairable_count": repairable_count,
59            "checks": {
60                "git_checked": false,
61                "messages": true,
62                "cases": true,
63                "archives": true,
64                "push_queue": true,
65                "templates": true,
66                "transactions": true,
67            },
68            "issues": issues,
69        }))
70    }
71
72    pub fn doctor_repair(&self, confirm: bool) -> Result<Value> {
73        self.require_workspace()?;
74        if !confirm {
75            return Err(AppError::new(
76                "confirm_required",
77                "doctor repair requires --confirm",
78            )
79            .with_hint("Inspect with `afmail doctor`; apply repairs with `afmail doctor repair --confirm`.")
80            .with_details(json!({
81                "suggested_commands": [
82                    "afmail doctor",
83                    "afmail doctor repair --confirm"
84                ]
85            })));
86        }
87        self.ensure_no_incomplete_transactions()?;
88        let before = self.doctor_issues()?;
89        let deprecated_state_removed_count = self.remove_deprecated_message_state_sidecars()?;
90        let cache = self.rebuild_message_cache_from_eml()?;
91        for path in message_json_paths(&self.root)? {
92            if let Ok(message) = read_message(&path) {
93                self.persist_message_remote(&message)?;
94            }
95        }
96        let rendered = self.render_refresh()?;
97        let after = self.doctor_issues()?;
98        Ok(json!({
99            "code": "doctor_repair",
100            "confirmed": true,
101            "before_issue_count": before.len(),
102            "after_issue_count": after.len(),
103            "message_cache_rebuilt_count": cache.rebuilt_count,
104            "text_cache_removed_count": cache.removed_text_cache_count,
105            "deprecated_state_removed_count": deprecated_state_removed_count,
106            "render": rendered,
107            "remaining_issues": after,
108        }))
109    }
110
111    fn doctor_issues(&self) -> Result<Vec<DoctorIssue>> {
112        let mut issues = Vec::new();
113        self.check_transactions(&mut issues)?;
114        self.check_messages(&mut issues)?;
115        self.check_case_refs(&mut issues)?;
116        self.check_archive_refs(&mut issues)?;
117        self.check_push_overlay(&mut issues)?;
118        self.check_templates(&mut issues)?;
119        Ok(issues)
120    }
121
122    fn check_transactions(&self, issues: &mut Vec<DoctorIssue>) -> Result<()> {
123        for transaction in self.incomplete_transactions()? {
124            issues.push(DoctorIssue::error(
125                "transaction_incomplete",
126                format!(
127                    "incomplete local transaction {} ({})",
128                    transaction.transaction_id, transaction.kind
129                ),
130                Some(format!(
131                    ".afmail/transactions/{}.json",
132                    transaction.transaction_id
133                )),
134            ));
135        }
136        Ok(())
137    }
138
139    fn check_messages(&self, issues: &mut Vec<DoctorIssue>) -> Result<()> {
140        let mut ids = BTreeSet::new();
141        for path in message_json_paths(&self.root)? {
142            let rel = rel_path(&self.root, &path);
143            let message = match read_message(&path) {
144                Ok(message) => message,
145                Err(err) => {
146                    issues.push(DoctorIssue::error(
147                        "message_cache_invalid",
148                        err.message,
149                        Some(rel),
150                    ));
151                    continue;
152                }
153            };
154            ids.insert(message.message_id.clone());
155            let eml = self
156                .root
157                .join(format!(".afmail/messages/{}.eml", message.message_id));
158            if !eml.is_file() {
159                issues.push(DoctorIssue::error(
160                    "message_eml_missing",
161                    format!("missing raw .eml for {}", message.message_id),
162                    Some(rel_path(&self.root, &eml)),
163                ));
164            }
165            if message
166                .remote
167                .as_ref()
168                .is_some_and(|remote| !remote.locations.is_empty())
169            {
170                let remote = self.root.join(format!(
171                    ".afmail/messages/{}.remote.json",
172                    message.message_id
173                ));
174                if !remote.is_file() {
175                    issues.push(DoctorIssue::warning(
176                        "message_remote_missing",
177                        format!("missing remote sidecar for {}", message.message_id),
178                        Some(rel_path(&self.root, &remote)),
179                        true,
180                    ));
181                }
182            }
183        }
184        for entry in read_optional_dir(&self.root.join(".afmail/messages"), "read message state")? {
185            let path = entry.path();
186            let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
187                continue;
188            };
189            if name.ends_with(".state.json") {
190                issues.push(DoctorIssue::warning(
191                    "message_state_deprecated",
192                    format!("deprecated message state sidecar: {name}"),
193                    Some(rel_path(&self.root, &path)),
194                    true,
195                ));
196                continue;
197            }
198            if let Some(message_id) = name.strip_suffix(".remote.json") {
199                if !ids.contains(message_id) && !self.message_path(message_id).is_file() {
200                    issues.push(DoctorIssue::warning(
201                        "message_sidecar_orphaned",
202                        format!("sidecar has no materialized message cache: {message_id}"),
203                        Some(rel_path(&self.root, &path)),
204                        true,
205                    ));
206                }
207            }
208        }
209        Ok(())
210    }
211
212    fn remove_deprecated_message_state_sidecars(&self) -> Result<usize> {
213        let mut removed = 0usize;
214        for entry in read_optional_dir(&self.root.join(".afmail/messages"), "read message state")? {
215            let path = entry.path();
216            if path
217                .file_name()
218                .and_then(|name| name.to_str())
219                .is_some_and(|name| name.ends_with(".state.json"))
220            {
221                remove_file(&path)?;
222                removed += 1;
223            }
224        }
225        Ok(removed)
226    }
227
228    fn check_case_refs(&self, issues: &mut Vec<DoctorIssue>) -> Result<()> {
229        for (case_uid, case_path) in self.all_case_entries()? {
230            let messages = read_case_messages(&case_path, &case_uid)?;
231            for message_id in messages.message_ids() {
232                if !self.message_path(&message_id).is_file() {
233                    let mut issue = DoctorIssue::error(
234                        "case_message_ref_broken",
235                        format!("case {case_uid} references missing message {message_id}"),
236                        Some(rel_path(&self.root, &case_json_path(&case_path))),
237                    );
238                    issue.refs = vec![case_uid.clone(), message_id];
239                    issues.push(issue);
240                }
241            }
242        }
243        Ok(())
244    }
245
246    fn check_archive_refs(&self, issues: &mut Vec<DoctorIssue>) -> Result<()> {
247        for archive_uid in self.archive_message_category_ids()? {
248            let archive = self.read_archive_messages(&archive_uid)?;
249            for item in archive.items {
250                if !self.message_path(&item.message_id).is_file() {
251                    let mut issue = DoctorIssue::error(
252                        "archive_message_ref_broken",
253                        format!(
254                            "archive {archive_uid} references missing message {}",
255                            item.message_id
256                        ),
257                        Some(rel_path(
258                            &self.root,
259                            &self.archive_message_json_path(&archive_uid),
260                        )),
261                    );
262                    issue.refs = vec![archive_uid.clone(), item.message_id];
263                    issues.push(issue);
264                }
265            }
266        }
267        Ok(())
268    }
269
270    fn check_push_overlay(&self, issues: &mut Vec<DoctorIssue>) -> Result<()> {
271        let items = crate::push_queue::pending_items(&self.root)?;
272        let mut pending_by_message: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
273        for item in items {
274            for message_id in item.message_ids() {
275                pending_by_message
276                    .entry(message_id.clone())
277                    .or_default()
278                    .insert(item.push_id.clone());
279                if !self.message_path(message_id).is_file() {
280                    issues.push(DoctorIssue::error(
281                        "push_message_ref_broken",
282                        format!(
283                            "push {} references missing message {message_id}",
284                            item.push_id
285                        ),
286                        Some(format!(".afmail/push/{}.json", item.push_id)),
287                    ));
288                }
289            }
290        }
291        for path in message_json_paths(&self.root)? {
292            let Ok(message) = read_message(&path) else {
293                continue;
294            };
295            let expected = pending_by_message
296                .remove(&message.message_id)
297                .unwrap_or_default();
298            let actual = message
299                .workspace
300                .push
301                .as_ref()
302                .map(|push| {
303                    push.pending
304                        .iter()
305                        .map(|pending| pending.push_id.clone())
306                        .collect::<BTreeSet<_>>()
307                })
308                .unwrap_or_default();
309            if expected != actual && (!expected.is_empty() || !actual.is_empty()) {
310                issues.push(DoctorIssue::warning(
311                    "push_overlay_drift",
312                    format!(
313                        "message {} push overlay differs from queue",
314                        message.message_id
315                    ),
316                    Some(rel_path(&self.root, &path)),
317                    true,
318                ));
319            }
320        }
321        Ok(())
322    }
323
324    fn check_templates(&self, issues: &mut Vec<DoctorIssue>) -> Result<()> {
325        let legacy_templates = self.root.join(".afmail/templates");
326        if legacy_templates.exists() {
327            issues.push(DoctorIssue::warning(
328                "legacy_template_dir",
329                ".afmail/templates is obsolete; workspace templates now live under templates/",
330                Some(".afmail/templates".to_string()),
331                false,
332            ));
333        }
334        let language = self.template_language()?;
335        let mut renderer = MarkdownTemplateRenderer::new(&self.root, language);
336        for key in TemplateKey::ALL {
337            if let Err(err) = renderer.render(key, &minimal_template_context(language)) {
338                issues.push(DoctorIssue::error(
339                    "template_render_failed",
340                    err.message,
341                    Some(key.as_str().to_string()),
342                ));
343            }
344        }
345        Ok(())
346    }
347}
348
349fn read_optional_dir(path: &Path, context: &str) -> Result<Vec<fs::DirEntry>> {
350    if !path.exists() {
351        return Ok(Vec::new());
352    }
353    read_dir(path, context)
354}
355
356fn minimal_template_context(language: TemplateLanguage) -> Value {
357    json!({
358        "items": [],
359        "messages": [],
360        "workspaces": ["."],
361        "frontmatter": {
362            "kind": "doctor",
363            "message_id": "message_doctor",
364            "message_ids": ["message_doctor"],
365            "case_uid": "c20260609001",
366            "case_name": "doctor",
367            "archive_uid": "a20260609001",
368            "archive_name": "doctor",
369            "status": "active",
370            "message_count": 0,
371            "attachment_count": 0,
372            "generated_rfc3339": "2026-06-09T00:00:00Z",
373            "added_rfc3339": "2026-06-09T00:00:00Z",
374            "suggested_case_uids": [],
375            "suggested_reason": "",
376            "suggested_reason_yaml": "",
377        },
378        "message": {
379            "schema_name": "message",
380            "schema_version": 1,
381            "message_id": "message_doctor",
382            "from": "",
383            "subject": "",
384            "to": [],
385            "cc": [],
386            "bcc": [],
387            "attachments": [],
388            "workspace": {"status": "triage"},
389        },
390        "view": {
391            "language": language.as_str(),
392            "title": "doctor",
393            "status": "active",
394            "status_label": "active",
395            "message_count": 0,
396            "attachment_count": 0,
397            "generated_rfc3339": "2026-06-09T00:00:00Z",
398            "added_rfc3339": "2026-06-09T00:00:00Z",
399            "summary": "doctor",
400            "conversation": "",
401            "related_messages": [],
402            "suggested_case_uids": [],
403            "suggested_reason": "",
404            "body_text_visible_block": "\n",
405            "body_text_fence": "```",
406            "display_heading": "doctor",
407            "security": {
408                "authentication": {
409                    "check": false,
410                    "has_results": false,
411                    "spf": "missing",
412                    "dkim": "missing",
413                    "dmarc": "missing",
414                    "dmarc_policy": null,
415                    "authenticated_domain": null,
416                    "from_domain": null,
417                    "alignment": "unknown",
418                },
419                "possible_bcc": false,
420                "reply_to_differs": false,
421                "reply_to_recipients": "",
422                "sender_differs": false,
423                "sender": "",
424                "mailing_list": "",
425                "mailing_list_headers": "",
426            },
427            "hints": [],
428            "attachments": [],
429        },
430        "config": {
431            "archive": {
432                "message_index": {
433                    "item_fields": [],
434                },
435            },
436        },
437        "archive": {
438            "archive_uid": "a20260609001",
439            "archive_name": "doctor",
440        },
441        "case": {
442            "case_uid": "c20260609001",
443            "case_name": "doctor",
444            "collection_uid": "c20260609001",
445            "collection_name": "doctor",
446            "status": "active",
447        },
448        "item": {},
449    })
450}